/**
 * @file StreamHub.cpp
 * @brief StreamHub top-level orchestrator implementation.
 */

#include "StreamHub.h"
#include "Environment/Linux/HighResolutionTimer.h"
#include "LTTB.h"
#include "AdvancedErrorManagement.h"
#include "Sleep.h"
#include "HighResolutionTimer.h"
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <stdarg.h>
#include <time.h>

namespace StreamHub {

using MARTe::Sleep;

/**
 * @brief printf-append into a heap buffer, growing it on demand.
 * @return false only on encoding error.
 */
static bool JsonAppendf(char *&buf, MARTe::uint32 &len, MARTe::uint32 &cap,
                        const char *fmt, ...) {
    for (;;) {
        va_list ap;
        va_start(ap, fmt);
        const int wrote = vsnprintf(buf + len, cap - len, fmt, ap);
        va_end(ap);
        if (wrote < 0) { return false; }
        if (static_cast<MARTe::uint32>(wrote) < (cap - len)) {
            len += static_cast<MARTe::uint32>(wrote);
            return true;
        }
        /* Grow and retry */
        MARTe::uint32 newCap = cap * 2u;
        while ((newCap - len) <= static_cast<MARTe::uint32>(wrote)) {
            newCap *= 2u;
        }
        char *nb = new char[newCap];
        memcpy(nb, buf, len);
        delete[] buf;
        buf = nb;
        cap = newCap;
    }
}

/*---------------------------------------------------------------------------*/
/* Constructor / Destructor                                                   */
/*---------------------------------------------------------------------------*/

StreamHub::StreamHub()
    : numSessions_(0u),
      wsPort_(8090u),
      maxPoints_(20000u),
      pushRateHz_(30u),
      maxPushPoints_(50u),
      statsRateHz_(1u),
      ringTemporal_(1000000u),
      ringScalar_(100000u),
      nextSourceId_(1u),
      running_(false),
      tickCount_(0u),
      pendingMaxPointsSet_(false),
      pendingMaxPoints_(0u),
      pushBuf_(static_cast<uint8 *>(0)),
      lttbT_(static_cast<float64 *>(0)),
      lttbV_(static_cast<float64 *>(0)),
      pushT_(static_cast<float64 *>(0)),
      pushV_(static_cast<float64 *>(0)),
      lastTrigState_(kTrigIdle),
      rearmPending_(false),
      rearmAtWallS_(0.0) {
    memset(&recorderCfg_, 0, sizeof(recorderCfg_));
    for (uint32 i = 0u; i < kMaxSessions; i++) {
        sessionActive_[i]   = false;
        configBroadcast_[i] = false;
        for (uint32 s = 0u; s < UDPSS_MAX_SIGNALS; s++) {
            pushCursor_[i][s] = 0u;
        }
        /* All session slots share the hub trigger engine. */
        sessions_[i].SetTriggerEngine(&trigger_);
    }
}

StreamHub::~StreamHub() {
    Stop();
    if (pushBuf_ != static_cast<uint8 *>(0)) {
        delete[] pushBuf_;
        pushBuf_ = static_cast<uint8 *>(0);
    }
    if (lttbT_ != static_cast<float64 *>(0)) {
        delete[] lttbT_;
        lttbT_ = static_cast<float64 *>(0);
    }
    if (lttbV_ != static_cast<float64 *>(0)) {
        delete[] lttbV_;
        lttbV_ = static_cast<float64 *>(0);
    }
    if (pushT_ != static_cast<float64 *>(0)) {
        delete[] pushT_;
        pushT_ = static_cast<float64 *>(0);
    }
    if (pushV_ != static_cast<float64 *>(0)) {
        delete[] pushV_;
        pushV_ = static_cast<float64 *>(0);
    }
}

/*---------------------------------------------------------------------------*/
/* Initialise                                                                 */
/*---------------------------------------------------------------------------*/

bool StreamHub::Initialise(StructuredDataI &cfg) {
    /* Use uint32 as the read buffer — never bool, which MARTe2 converts from
     * integer values incorrectly (8090 -> false), leading to zero port/points. */
    uint32 tmp = 0u;

    if (cfg.Read("WSPort",       tmp)) { wsPort_        = static_cast<uint16>(tmp); }
    if (cfg.Read("MaxPoints",    tmp)) { maxPoints_      = tmp; }
    if (cfg.Read("PushRate",     tmp)) { pushRateHz_     = (tmp > 0u) ? tmp : 30u; }
    if (cfg.Read("MaxPushPoints",tmp)) { maxPushPoints_  = (tmp > 0u) ? tmp : 50u; }
    if (cfg.Read("StatsRate",    tmp)) { statsRateHz_    = (tmp > 0u) ? tmp : 1u; }
    if (cfg.Read("RingTemporal", tmp)) { ringTemporal_   = (tmp > 0u) ? tmp : 1000000u; }
    if (cfg.Read("RingScalar",   tmp)) { ringScalar_     = (tmp > 0u) ? tmp : 100000u; }

    sourcesFile_ = "streamhub_sources.json";
    StreamString sf;
    if (cfg.Read("SourcesFile", sf)) { sourcesFile_ = sf; }

    /* Parse +History block (optional).
     * StandardParser stores the '+' prefix in the node name, so we try both. */
    if (cfg.MoveRelative("+History") || cfg.MoveRelative("History")) {
        history_.Initialise(cfg);
        cfg.MoveToAncestor(1u);
    }

    /* Parse +Recorder block (optional). Defaults: disabled, auto-start when
     * enabled, 256 MiB files, keep 8, 8 MiB staging, 5 s fsync cadence,
     * 500 MiB free-disk guard, all signals. MB sizes are converted to bytes. */
    recorderCfg_.enabled         = false;
    recorderCfg_.autoStart       = true;
    strncpy(recorderCfg_.directory, "streamhub_rec",
            sizeof(recorderCfg_.directory) - 1u);
    recorderCfg_.maxFileBytes    = 256u * 1024u * 1024u;
    recorderCfg_.keepFiles       = 8u;
    recorderCfg_.stagingBytes    = 8u * 1024u * 1024u;
    recorderCfg_.flushIntervalSec = 5u;
    recorderCfg_.minDiskFreeMB   = 500u;
    strncpy(recorderCfg_.signals, "all", sizeof(recorderCfg_.signals) - 1u);

    if (cfg.MoveRelative("+Recorder") || cfg.MoveRelative("Recorder")) {
        uint32 rtmp = 0u;
        if (cfg.Read("Enabled",   rtmp)) { recorderCfg_.enabled   = (rtmp != 0u); }
        if (cfg.Read("AutoStart", rtmp)) { recorderCfg_.autoStart = (rtmp != 0u); }
        StreamString rdir;
        if (cfg.Read("Directory", rdir)) {
            strncpy(recorderCfg_.directory, rdir.Buffer(),
                    sizeof(recorderCfg_.directory) - 1u);
        }
        if (cfg.Read("MaxFileMB", rtmp) && rtmp > 0u) {
            recorderCfg_.maxFileBytes = rtmp * 1024u * 1024u;
        }
        if (cfg.Read("KeepFiles", rtmp)) { recorderCfg_.keepFiles = rtmp; }
        if (cfg.Read("StagingMB", rtmp) && rtmp > 0u) {
            recorderCfg_.stagingBytes = rtmp * 1024u * 1024u;
        }
        if (cfg.Read("FlushIntervalSec", rtmp) && rtmp > 0u) {
            recorderCfg_.flushIntervalSec = rtmp;
        }
        if (cfg.Read("MinDiskFreeMB", rtmp)) { recorderCfg_.minDiskFreeMB = rtmp; }
        StreamString rsigs;
        if (cfg.Read("Signals", rsigs)) {
            strncpy(recorderCfg_.signals, rsigs.Buffer(),
                    sizeof(recorderCfg_.signals) - 1u);
        }
        cfg.MoveToAncestor(1u);
    }

    /* Allocate scratch buffers */
    pushBuf_ = new uint8[kPushBufSize];
    lttbT_   = new float64[maxPushPoints_];
    lttbV_   = new float64[maxPushPoints_];
    pushT_   = new float64[kPushScratchPts];
    pushV_   = new float64[kPushScratchPts];

    /* Parse Sources block (unconditionally — not gated on a bool read result) */
    if (cfg.MoveRelative("Sources")) {
        uint32 nChildren = cfg.GetNumberOfChildren();
        for (uint32 i = 0u; i < nChildren && numSessions_ < kMaxSessions; i++) {
            if (!cfg.MoveToChild(i)) { continue; }

            /* Node name = source id */
            StreamString nodeId;
            const MARTe::char8 *nname = cfg.GetName();
            if (nname != static_cast<const MARTe::char8 *>(0)) {
                nodeId = nname;
            }

            char label[128] = "";
            char addr[64]   = "127.0.0.1";
            uint32 port32   = 44500u;
            char mcGroup[64] = "";
            uint32 dataPort32 = 0u;

            StreamString lbl;
            if (cfg.Read("Label", lbl)) {
                strncpy(label, lbl.Buffer(), sizeof(label) - 1u);
            }
            StreamString addrStr;
            if (cfg.Read("Addr", addrStr)) {
                strncpy(addr, addrStr.Buffer(), sizeof(addr) - 1u);
            } 
            cfg.Read("Port", port32);

            StreamString mcGrpStr;
            if (cfg.Read("MulticastGroup", mcGrpStr)) {
                strncpy(mcGroup, mcGrpStr.Buffer(), sizeof(mcGroup) - 1u);
            }
            cfg.Read("DataPort", dataPort32);

            sessions_[numSessions_].SetRingCapacities(ringTemporal_, ringScalar_);
            bool ok = sessions_[numSessions_].Initialise(
                nodeId.Buffer(), label, addr,
                static_cast<uint16>(port32), maxPoints_,
                mcGroup, static_cast<uint16>(dataPort32));

            if (ok) {
                sessions_[numSessions_].SetRecorderConfig(recorderCfg_);
                ok = sessions_[numSessions_].Start();
            }
            if (ok) {
                sessionActive_[numSessions_] = true;
                numSessions_++;
                REPORT_ERROR_STATIC(MARTe::ErrorManagement::Information,
                    "StreamHub: session '%s' started (%s:%u).",
                    nodeId.Buffer(), addr, static_cast<uint32>(port32));
            } else {
                REPORT_ERROR_STATIC(MARTe::ErrorManagement::Warning,
                    "StreamHub: failed to start session '%s'.", nodeId.Buffer());
            }

            cfg.MoveToAncestor(1u);
        }
        cfg.MoveToAncestor(1u); /* back to root */
    }

    /* Start any persisted dynamic sources (Go SourceConfig schema). */
    LoadSourcesFile();

    REPORT_ERROR_STATIC(MARTe::ErrorManagement::Information,
        "StreamHub: initialised with %u session(s), WSPort=%u, MaxPoints=%u, PushRate=%u Hz.",
        numSessions_, static_cast<uint32>(wsPort_), maxPoints_, pushRateHz_);

    return true;
}

/*---------------------------------------------------------------------------*/
/* Run / Stop                                                                */
/*---------------------------------------------------------------------------*/

bool StreamHub::Run() {
    if (!wsServer_.Start(wsPort_, this)) {
        REPORT_ERROR_STATIC(MARTe::ErrorManagement::FatalError,
                            "StreamHub: failed to start WebSocket server.");
        return false;
    }
    REPORT_ERROR_STATIC(MARTe::ErrorManagement::Information,
                        "StreamHub: push loop started at %u Hz.", pushRateHz_);

    running_   = true;
    tickCount_ = 0u;

    /* Period in microseconds */
    uint64 periodUs = (pushRateHz_ > 0u) ? (1000000u / pushRateHz_) : 33333u;

    while (running_) {
        uint64 t0 = MARTe::HighResolutionTimer::Counter();

        PushData();

        /* Trigger servicing: capture finalization, auto-rearm, state events */
        {
            struct timespec tsNow;
            (void) clock_gettime(CLOCK_REALTIME, &tsNow);
            const float64 wallNowS = static_cast<float64>(tsNow.tv_sec) +
                                     static_cast<float64>(tsNow.tv_nsec) * 1.0e-9;
            TriggerTick(wallNowS);
        }

        /* Stats at statsRateHz_ */
        uint32 statsDivisor = (statsRateHz_ > 0u && pushRateHz_ > 0u)
                              ? (pushRateHz_ / statsRateHz_) : pushRateHz_;
        if (statsDivisor == 0u) { statsDivisor = 1u; }
        if ((tickCount_ % statsDivisor) == 0u) {
            PushStats();
        }

        /* History: flush headers at the configured interval, then re-broadcast
         * historyInfo so new clients (or clients that saw empty signal lists
         * before the first data was written) pick up the current state. */
        if (history_.IsEnabled()) {
            uint32 flushIntervalTicks = pushRateHz_ * 5u; /* default 5 s */
            if (pushRateHz_ > 0u) {
                flushIntervalTicks = pushRateHz_ * 5u;
            }
            if (flushIntervalTicks == 0u) { flushIntervalTicks = 1u; }
            if ((tickCount_ % flushIntervalTicks) == 0u) {
                history_.FlushHeaders();
                /* Re-broadcast so clients see updated signal counts and
                 * time ranges after the first batch of data is written. */
                uint32 cap2 = 8192u;
                char  *hbuf = new char[cap2];
                uint32 hoff = 0u;
                JsonAppendf(hbuf, hoff, cap2, "{\"type\":\"historyInfo\",");
                history_.AppendInfoJSON(hbuf, hoff, cap2);
                JsonAppendf(hbuf, hoff, cap2, "}");
                wsServer_.BroadcastText(hbuf, hoff);
                delete[] hbuf;
            }
        }

        tickCount_++;

        /* Sleep for remainder of period */
        uint64 t1   = MARTe::HighResolutionTimer::Counter();
        uint64 freq = MARTe::HighResolutionTimer::Frequency();
        uint64 elapsedUs = ((t1 - t0) * 1000000u) / freq;
        if (periodUs - elapsedUs > 1000) {
            Sleep::MSec(static_cast<uint32>((periodUs - elapsedUs) / 1000u));
        }
    }

    wsServer_.Stop();
    return true;
}

void StreamHub::Stop() {
    running_ = false;
}

/*---------------------------------------------------------------------------*/
/* Push helpers                                                               */
/*---------------------------------------------------------------------------*/

void StreamHub::PushData() {
    /* Apply a deferred setMaxPoints request on the push thread (the WS read
     * threads must not touch session read state concurrently). */
    if (pendingMaxPointsSet_) {
        pendingMaxPointsSet_ = false;
        maxPoints_ = pendingMaxPoints_;
        for (uint32 i = 0u; i < kMaxSessions; i++) {
            if (sessionActive_[i]) {
                (void) sessions_[i].SetMaxPoints(maxPoints_);
            }
        }
        char resp[128];
        snprintf(resp, sizeof(resp),
                 "{\"type\":\"maxPointsUpdated\",\"maxPoints\":%u}", maxPoints_);
        wsServer_.BroadcastText(resp, static_cast<uint32>(strlen(resp)));
    }

    /* Wall-clock seconds for recorder fsync cadence and file timestamps. */
    uint32 nowSec = 0u;
    {
        struct timespec tsRec;
        (void) clock_gettime(CLOCK_REALTIME, &tsRec);
        nowSec = static_cast<uint32>(tsRec.tv_sec);
    }

    for (uint32 i = 0u; i < kMaxSessions; i++) {
        if (!sessionActive_[i]) { continue; }
        if (!sessions_[i].IsConfigured()) { continue; }

        /* First time we see this session become configured: broadcast its signal
         * list to all currently connected WS clients.  This handles the timing
         * race where a client connected before the UDPStreamer sent its CONFIG
         * packet, so OnWSClientConnected() could not send it then. */
        if (!configBroadcast_[i]) {
            configBroadcast_[i] = true;
            /* New (or re-)configuration: restart push cursors so the first
             * frame starts from the freshly allocated rings. */
            for (uint32 s = 0u; s < UDPSS_MAX_SIGNALS; s++) {
                pushCursor_[i][s] = 0u;
            }
            BroadcastSources();
            BroadcastConfig(i);

            /* Open history files for this source */
            if (history_.IsEnabled()) {
                StreamString sid = sessions_[i].GetId();
                history_.OnSourceConfigured(i, sid.Buffer(), sessions_[i]);
            }
        }

        /* Always serialize (advancing the per-signal cursors), even with no
         * connected clients — avoids a huge backlog burst on first connect. */
        uint32 frameLen = SerializeBinaryFrame(i, pushBuf_, kPushBufSize);
        if (frameLen > 0u) {
            wsServer_.BroadcastBinary(pushBuf_, frameLen);
        }

        /* Write to disk history (separate read cursors, own decimation) */
        if (history_.IsEnabled()) {
            history_.WriteTick(i, sessions_[i]);
        }

        /* Binary recorder: all disk I/O happens here on the push thread. */
        if (sessions_[i].IsRecorderEnabled()) {
            sessions_[i].RecorderFlushTick(nowSec);
        }
    }
}

uint32 StreamHub::SerializeBinaryFrame(uint32 sessionIdx,
                                        uint8 *buf, uint32 bufSize) {
    UDPSourceSession &sess = sessions_[sessionIdx];

    StreamString sid = sess.GetId();
    const char  *id  = sid.Buffer();
    uint32       idLen = static_cast<uint32>(strlen(id));
    if (idLen > 255u) { idLen = 255u; }

    uint32 numSigs = sess.GetNumSignals();
    if (numSigs == 0u) { return 0u; }

    uint32 offset = 0u;

    /* Header: [1] version, [1] idLen, [idLen] id, [4] numSignals */
    if (offset + 1u + 1u + idLen + 4u > bufSize) {
        return 0u;
    }
    buf[offset++] = 1u; /* version */
    buf[offset++] = static_cast<uint8>(idLen);
    memcpy(buf + offset, id, idLen);
    offset += idLen;

    buf[offset++] = static_cast<uint8>( numSigs        & 0xFFu);
    buf[offset++] = static_cast<uint8>((numSigs >>  8) & 0xFFu);
    buf[offset++] = static_cast<uint8>((numSigs >> 16) & 0xFFu);
    buf[offset++] = static_cast<uint8>((numSigs >> 24) & 0xFFu);

    uint32 sigsWritten = 0u;

    for (uint32 s = 0u; s < numSigs; s++) {
        MARTe::UDPSSignalDescriptor desc;
        if (!sess.GetSignalDescriptor(s, desc)) { continue; }

        /* Read only the samples written since the last tick (per-signal
         * cursor).  Re-reading overlapping windows and re-LTTBing them is
         * what corrupted the traces before. */
        uint32 nRaw = sess.ReadSignalSince(s, pushCursor_[sessionIdx][s],
                                           pushT_, pushV_, kPushScratchPts);
        if (nRaw == 0u) { continue; }

        /* LTTB decimation only for temporal (multi-element, sample-timed)
         * signals — Go hub policy.  Scalars and PACKET-timed arrays are
         * pushed verbatim (their per-tick batches are small). */
        const uint32 nElems = desc.numRows * ((desc.numCols > 0u) ? desc.numCols : 1u);
        const bool temporal = (nElems > 1u) &&
                              (desc.timeMode != MARTe::UDPS_TIMEMODE_PACKET);

        const float64 *tOut;
        const float64 *vOut;
        uint32 nOut;
        if (temporal && (nRaw > maxPushPoints_)) {
            nOut = LTTBDecimate(pushT_, pushV_, nRaw,
                                lttbT_, lttbV_, maxPushPoints_);
            tOut = lttbT_;
            vOut = lttbV_;
        } else {
            nOut = nRaw;
            tOut = pushT_;
            vOut = pushV_;
        }

        const char *sigName = desc.name;
        uint32      keyLen  = static_cast<uint32>(strlen(sigName));
        if (keyLen > 65535u) { keyLen = 65535u; }

        uint32 frameBytes = 2u + keyLen + 4u + nOut * 8u + nOut * 8u;
        if (offset + frameBytes > bufSize) { break; }

        /* [2] keyLen, [K] key */
        buf[offset++] = static_cast<uint8>( keyLen       & 0xFFu);
        buf[offset++] = static_cast<uint8>((keyLen >> 8) & 0xFFu);
        memcpy(buf + offset, sigName, keyLen);
        offset += keyLen;

        /* [4] pairCount */
        buf[offset++] = static_cast<uint8>( nOut        & 0xFFu);
        buf[offset++] = static_cast<uint8>((nOut >>  8) & 0xFFu);
        buf[offset++] = static_cast<uint8>((nOut >> 16) & 0xFFu);
        buf[offset++] = static_cast<uint8>((nOut >> 24) & 0xFFu);

        /* [N×8] time array, [N×8] value array (little-endian) */
        memcpy(buf + offset, tOut, nOut * sizeof(float64));
        offset += nOut * 8u;
        memcpy(buf + offset, vOut, nOut * sizeof(float64));
        offset += nOut * 8u;

        sigsWritten++;
    }

    if (sigsWritten == 0u) { return 0u; }

    /* Patch numSignals field with actual written count */
    uint32 numSigsOffset = 2u + idLen;
    buf[numSigsOffset]     = static_cast<uint8>( sigsWritten        & 0xFFu);
    buf[numSigsOffset + 1u] = static_cast<uint8>((sigsWritten >>  8) & 0xFFu);
    buf[numSigsOffset + 2u] = static_cast<uint8>((sigsWritten >> 16) & 0xFFu);
    buf[numSigsOffset + 3u] = static_cast<uint8>((sigsWritten >> 24) & 0xFFu);

    return offset;
}

/*---------------------------------------------------------------------------*/
/* Stats broadcast                                                            */
/*---------------------------------------------------------------------------*/

void StreamHub::PushStats() {
    (void) sessionsMutex_.FastLock();
    const uint32 nActive = numSessions_;
    sessionsMutex_.FastUnLock();

    if (nActive == 0u) { return; }

    /* Build JSON (Go hub StatInfo field names + "state"):
     * {"type":"stats","sources":{"id":{"totalReceived":N,...,"cycleHist":[...]},...}}
     */
    uint32 cap = 8192u;
    char  *buf = new char[cap];
    uint32 off = 0u;

    JsonAppendf(buf, off, cap, "{\"type\":\"stats\",\"sources\":{");

    bool first = true;
    for (uint32 i = 0u; i < kMaxSessions; i++) {
        if (!sessionActive_[i]) { continue; }
        UDPSourceStats st  = sessions_[i].GetStats();
        StreamString   sid = sessions_[i].GetId();

        JsonAppendf(buf, off, cap,
            "%s\"%s\":{"
            "\"state\":\"%s\","
            "\"totalReceived\":%llu,"
            "\"totalLost\":%llu,"
            "\"rateHz\":%.6g,"
            "\"rateStdHz\":%.6g,"
            "\"fragsPerCycle\":%.6g,"
            "\"bytesPerCycle\":%.6g,"
            "\"cycleAvgMs\":%.6g,"
            "\"cycleStdMs\":%.6g,"
            "\"cycleMinMs\":%.6g,"
            "\"cycleMaxMs\":%.6g,"
            "\"cycleHistMin\":%.6g,"
            "\"cycleHistMax\":%.6g,"
            "\"cycleHist\":[",
            (first ? "" : ","),
            sid.Buffer(),
            st.state.Buffer(),
            static_cast<unsigned long long>(st.totalReceived),
            static_cast<unsigned long long>(st.totalLost),
            st.rateHz,
            st.rateStdHz,
            st.fragsPerCycle,
            st.bytesPerCycle,
            st.cycleAvgMs,
            st.cycleStdMs,
            st.cycleMinMs,
            st.cycleMaxMs,
            st.cycleHistMin,
            st.cycleHistMax);

        if (st.histValid) {
            for (uint32 b = 0u; b < UDPS_STAT_HIST_BINS; b++) {
                JsonAppendf(buf, off, cap, "%s%u",
                            (b > 0u ? "," : ""), st.cycleHist[b]);
            }
        }
        JsonAppendf(buf, off, cap, "]}");
        first = false;
    }

    JsonAppendf(buf, off, cap, "}}");

    wsServer_.BroadcastText(buf, off);
    delete[] buf;
}

/*---------------------------------------------------------------------------*/
/* Sources / Config broadcast                                                 */
/*---------------------------------------------------------------------------*/

void StreamHub::BroadcastSources() {
    static const uint32 kBuf = 4096u;
    char *buf = new char[kBuf];
    uint32 off = 0u;

    off += static_cast<uint32>(snprintf(buf + off, kBuf - off,
        "{\"type\":\"sources\",\"sources\":["));

    bool first = true;
    for (uint32 i = 0u; i < kMaxSessions; i++) {
        if (!sessionActive_[i]) { continue; }
        UDPSourceStats st  = sessions_[i].GetStats();
        StreamString   sid = sessions_[i].GetId();
        StreamString   lbl = sessions_[i].GetLabel();
        StreamString   adr = sessions_[i].GetAddr();
        uint16         prt = sessions_[i].GetPort();

        if (off >= kBuf - 256u) { break; }
        /* Go hub shape: addr is the combined "host:port" string. */
        off += static_cast<uint32>(snprintf(buf + off, kBuf - off,
            "%s{\"id\":\"%s\",\"label\":\"%s\","
            "\"addr\":\"%s:%u\",\"state\":\"%s\"}",
            (first ? "" : ","),
            sid.Buffer(), lbl.Buffer(),
            adr.Buffer(), static_cast<uint32>(prt),
            st.state.Buffer()));
        first = false;
    }

    off += static_cast<uint32>(snprintf(buf + off, kBuf - off, "]}"));
    wsServer_.BroadcastText(buf, off);
    delete[] buf;
}

void StreamHub::BroadcastConfig(uint32 idx) {
    if ((idx >= kMaxSessions) || (!sessionActive_[idx])) { return; }
    UDPSourceSession &sess = sessions_[idx];
    if (!sess.IsConfigured()) { return; }

    StreamString sid  = sess.GetId();
    uint32       nsig = sess.GetNumSignals();
    uint8        pm   = sess.GetPublishMode();

    static const uint32 kBuf = 16384u;
    char *buf = new char[kBuf];
    uint32 off = 0u;

    off += static_cast<uint32>(snprintf(buf + off, kBuf - off,
        "{\"type\":\"config\",\"sourceId\":\"%s\","
        "\"publishMode\":%u,\"signals\":[",
        sid.Buffer(), static_cast<uint32>(pm)));

    for (uint32 s = 0u; s < nsig; s++) {
        MARTe::UDPSSignalDescriptor desc;
        if (!sess.GetSignalDescriptor(s, desc)) { continue; }

        if (off >= kBuf - 512u) { break; }
        /* Field names match the Go hub's UDPSSignalDescriptor JSON tags —
         * required by the SPA (numElements(sig) = numRows*numCols, etc.). */
        off += static_cast<uint32>(snprintf(buf + off, kBuf - off,
            "%s{\"name\":\"%s\","
            "\"typeCode\":%u,"
            "\"quantType\":%u,"
            "\"numDimensions\":%u,"
            "\"numRows\":%u,"
            "\"numCols\":%u,"
            "\"rangeMin\":%.9g,"
            "\"rangeMax\":%.9g,"
            "\"timeMode\":%u,"
            "\"samplingRate\":%.9g,"
            "\"timeSignalIdx\":%u,"
            "\"unit\":\"%s\"}",
            (s > 0u ? "," : ""),
            desc.name,
            static_cast<uint32>(desc.typeCode),
            static_cast<uint32>(desc.quantType),
            static_cast<uint32>(desc.numDimensions),
            desc.numRows,
            desc.numCols,
            desc.rangeMin,
            desc.rangeMax,
            static_cast<uint32>(desc.timeMode),
            desc.samplingRate,
            desc.timeSignalIdx,
            desc.unit));
    }

    off += static_cast<uint32>(snprintf(buf + off, kBuf - off, "]}"));
    wsServer_.BroadcastText(buf, off);
    delete[] buf;
}

/*---------------------------------------------------------------------------*/
/* WSCommandCallback                                                          */
/*---------------------------------------------------------------------------*/

void StreamHub::OnWSClientConnected() {
    REPORT_ERROR_STATIC(MARTe::ErrorManagement::Information,
                        "StreamHub: WebSocket client connected (%u total).",
                        wsServer_.ClientCount());
    /* Send current sources list and config to the new client */
    BroadcastSources();

    for (uint32 i = 0u; i < kMaxSessions; i++) {
        if (sessionActive_[i] && sessions_[i].IsConfigured()) {
            BroadcastConfig(i);
        }
    }

    /* Let the new client render the current trigger badge/buttons. */
    BroadcastTriggerState();

    /* Inform the new client about history availability. */
    if (history_.IsEnabled()) {
        /* Broadcast to all (simple; no unicast-on-connect path for broadcast). */
        uint32 cap2 = 8192u;
        char  *hbuf = new char[cap2];
        uint32 hoff = 0u;
        JsonAppendf(hbuf, hoff, cap2, "{\"type\":\"historyInfo\",");
        history_.AppendInfoJSON(hbuf, hoff, cap2);
        JsonAppendf(hbuf, hoff, cap2, "}");
        wsServer_.BroadcastText(hbuf, hoff);
        delete[] hbuf;
    }

    /* Inform the new client about the current MaxPoints setting. */
    {
        char mp[64];
        int n = snprintf(mp, sizeof(mp),
            "{\"type\":\"maxPointsUpdated\",\"maxPoints\":%u}", maxPoints_);
        if (n > 0) { wsServer_.BroadcastText(mp, static_cast<uint32>(n)); }
    }
}

void StreamHub::OnWSClientDisconnected() {
    REPORT_ERROR_STATIC(MARTe::ErrorManagement::Information,
                        "StreamHub: WebSocket client disconnected (%u remaining).",
                        wsServer_.ClientCount());
}

void StreamHub::OnWSCommand(const char *json, uint32 /*len*/, uint32 slotIdx) {
    char type[64] = "";
    if (!JsonGetString(json, "type", type, sizeof(type))) { return; }

    if      (strcmp(type, "addSource")    == 0) { HandleAddSource(json);    }
    else if (strcmp(type, "removeSource") == 0) { HandleRemoveSource(json); }
    else if (strcmp(type, "saveSources")  == 0) { HandleSaveSources();      }
    else if (strcmp(type, "getSources")   == 0) { HandleGetSources();       }
    else if (strcmp(type, "getConfig")    == 0) { HandleGetConfig(json);    }
    else if (strcmp(type, "getStats")     == 0) { HandleGetStats();         }
    else if (strcmp(type, "arm")          == 0) { HandleArm();              }
    else if (strcmp(type, "disarm")       == 0) { HandleDisarm();           }
    else if (strcmp(type, "rearm")        == 0) { HandleRearm();            }
    else if (strcmp(type, "trigStop")     == 0) { HandleTrigStop(json);     }
    else if (strcmp(type, "setTrigger")   == 0) { HandleSetTrigger(json);   }
    else if (strcmp(type, "zoom")         == 0) { HandleZoom(json, slotIdx); }
    else if (strcmp(type, "historyZoom")  == 0) { HandleHistoryZoom(json, slotIdx); }
    else if (strcmp(type, "historyInfo")  == 0) { HandleHistoryInfo(slotIdx); }
    else if (strcmp(type, "setMaxPoints") == 0) { HandleSetMaxPoints(json); }
    else if (strcmp(type, "recStart")     == 0) { HandleRecStart(json);     }
    else if (strcmp(type, "recStop")      == 0) { HandleRecStop(json);      }
    else if (strcmp(type, "recInfo")      == 0) { HandleRecInfo(slotIdx);   }
    else if (strcmp(type, "ping")         == 0) { HandlePing(slotIdx);      }
}

/*---------------------------------------------------------------------------*/
/* Command handlers                                                           */
/*---------------------------------------------------------------------------*/

void StreamHub::HandleAddSource(const char *json) {
    /* SPA shape: {"type":"addSource","label":L,"addr":"host:port",
     *             "multicastGroup":G?,"dataPort":N?} — the hub generates ids. */
    char label[128]   = "";
    char addr[80]     = "";
    char mcGroup[64]  = "";
    float64 dataPortF = 0.0;

    JsonGetString(json, "label",          label,   sizeof(label));
    JsonGetString(json, "addr",           addr,    sizeof(addr));
    JsonGetString(json, "multicastGroup", mcGroup, sizeof(mcGroup));
    JsonGetFloat(json,  "dataPort",       dataPortF);

    if (AddSourceInternal(label, addr, mcGroup,
                          static_cast<uint16>(dataPortF))) {
        BroadcastSources();
    }
}

bool StreamHub::AddSourceInternal(const char *label, const char *addrPort,
                                  const char *mcGroup, uint16 dataPort) {
    if ((addrPort == static_cast<const char *>(0)) || (addrPort[0] == '\0')) {
        return false;
    }

    /* Split "host:port" (Go-style combined address). */
    char host[64];
    strncpy(host, addrPort, sizeof(host) - 1u);
    host[sizeof(host) - 1u] = '\0';
    uint32 port = 44500u;
    char *colon = strrchr(host, ':');
    if (colon != static_cast<char *>(0)) {
        port = static_cast<uint32>(strtoul(colon + 1,
                                           static_cast<char **>(0), 10));
        *colon = '\0';
    }

    /* Whole add is serialized against other WS-thread add/remove requests.
     * The push thread never takes this mutex: it only observes the
     * sessionActive_[] flag, which is set last (after a successful Start). */
    (void) sessionsMutex_.FastLock();

    uint32 idx = kMaxSessions;
    for (uint32 i = 0u; i < kMaxSessions; i++) {
        if (!sessionActive_[i]) { idx = i; break; }
    }
    if (idx == kMaxSessions) {
        sessionsMutex_.FastUnLock();
        REPORT_ERROR_STATIC(MARTe::ErrorManagement::Warning,
                            "StreamHub: max sessions reached, ignoring addSource.");
        return false;
    }

    char id[16];
    snprintf(id, sizeof(id), "s%u", nextSourceId_++);

    /* Label defaults to the address (Go SourceManager.Add). */
    const char *lbl = ((label != static_cast<const char *>(0)) &&
                       (label[0] != '\0')) ? label : addrPort;

    configBroadcast_[idx] = false; /* set true by PushData on first config */
    for (uint32 s = 0u; s < UDPSS_MAX_SIGNALS; s++) {
        pushCursor_[idx][s] = 0u;
    }
    sessions_[idx].SetRingCapacities(ringTemporal_, ringScalar_);
    bool ok = sessions_[idx].Initialise(id, lbl, host,
                                        static_cast<uint16>(port), maxPoints_,
                                        mcGroup, dataPort);
    if (ok) {
        sessions_[idx].SetRecorderConfig(recorderCfg_);
        ok = sessions_[idx].Start();
    }

    if (ok) {
        sessionActive_[idx] = true;
        numSessions_++;
    }
    sessionsMutex_.FastUnLock();

    if (ok) {
        REPORT_ERROR_STATIC(MARTe::ErrorManagement::Information,
                            "StreamHub: added session '%s' (%s:%u).", id, host, port);
    }
    return ok;
}

void StreamHub::LoadSourcesFile() {
    if (sourcesFile_.Size() == 0u) { return; }

    FILE *f = fopen(sourcesFile_.Buffer(), "rb");
    if (f == static_cast<FILE *>(0)) { return; } /* missing file is fine */

    (void) fseek(f, 0, SEEK_END);
    const long fsz = ftell(f);
    (void) fseek(f, 0, SEEK_SET);
    if ((fsz <= 0) || (fsz > (1L << 20))) {
        (void) fclose(f);
        return;
    }
    char *data = new char[static_cast<uint32>(fsz) + 1u];
    const MARTe::osulong nRead = fread(data, 1u, static_cast<MARTe::osulong>(fsz), f);
    data[nRead] = '\0';
    (void) fclose(f);

    /* JSON array of flat objects — iterate over each {...} block. */
    uint32 nLoaded = 0u;
    const char *p = data;
    while ((p = strchr(p, '{')) != static_cast<const char *>(0)) {
        const char *end = strchr(p, '}');
        if (end == static_cast<const char *>(0)) { break; }

        uint32 objLen = static_cast<uint32>(end - p) + 1u;
        if (objLen > 1023u) { objLen = 1023u; }
        char obj[1024];
        memcpy(obj, p, objLen);
        obj[objLen] = '\0';

        char label[128]   = "";
        char addr[80]     = "";
        char mcGroup[64]  = "";
        float64 dataPortF = 0.0;
        JsonGetString(obj, "label",          label,   sizeof(label));
        JsonGetString(obj, "addr",           addr,    sizeof(addr));
        JsonGetString(obj, "multicastGroup", mcGroup, sizeof(mcGroup));
        JsonGetFloat(obj,  "dataPort",       dataPortF);

        if (AddSourceInternal(label, addr, mcGroup,
                              static_cast<uint16>(dataPortF))) {
            nLoaded++;
        }
        p = end + 1;
    }
    delete[] data;

    if (nLoaded > 0u) {
        REPORT_ERROR_STATIC(MARTe::ErrorManagement::Information,
            "StreamHub: loaded %u source(s) from '%s'.",
            nLoaded, sourcesFile_.Buffer());
    }
}

void StreamHub::HandleSaveSources() {
    if (sourcesFile_.Size() == 0u) { return; }

    FILE *f = fopen(sourcesFile_.Buffer(), "wb");
    if (f == static_cast<FILE *>(0)) {
        REPORT_ERROR_STATIC(MARTe::ErrorManagement::Warning,
            "StreamHub: cannot write sources file '%s'.", sourcesFile_.Buffer());
        return;
    }

    uint32 nSaved = 0u;
    (void) fprintf(f, "[\n");
    for (uint32 i = 0u; i < kMaxSessions; i++) {
        if (!sessionActive_[i]) { continue; }
        StreamString lbl = sessions_[i].GetLabel();
        StreamString adr = sessions_[i].GetAddr();
        StreamString mcg = sessions_[i].GetMulticastGroup();
        const uint16 prt = sessions_[i].GetPort();
        const uint16 dpt = sessions_[i].GetDataPort();

        (void) fprintf(f, "%s  {\n    \"label\": \"%s\",\n    \"addr\": \"%s:%u\"",
                       (nSaved > 0u) ? ",\n" : "",
                       lbl.Buffer(), adr.Buffer(), static_cast<uint32>(prt));
        if (mcg.Size() > 0u) {
            (void) fprintf(f, ",\n    \"multicastGroup\": \"%s\"", mcg.Buffer());
            if (dpt > 0u) {
                (void) fprintf(f, ",\n    \"dataPort\": %u",
                               static_cast<uint32>(dpt));
            }
        }
        (void) fprintf(f, "\n  }");
        nSaved++;
    }
    (void) fprintf(f, "\n]\n");
    (void) fclose(f);

    REPORT_ERROR_STATIC(MARTe::ErrorManagement::Information,
        "StreamHub: saved %u source(s) to '%s'.", nSaved, sourcesFile_.Buffer());
}

void StreamHub::HandleRemoveSource(const char *json) {
    char id[64] = "";
    JsonGetString(json, "id", id, sizeof(id));

    /* Slots are never compacted (sessions are not copyable): deactivate the
     * slot so all iteration paths skip it, then stop the client thread.  The
     * slot is reused by the next AddSourceInternal. */
    (void) sessionsMutex_.FastLock();
    uint32 found = kMaxSessions;
    for (uint32 i = 0u; i < kMaxSessions; i++) {
        if (!sessionActive_[i]) { continue; }
        StreamString sid = sessions_[i].GetId();
        if (strcmp(sid.Buffer(), id) == 0) {
            found = i;
            break;
        }
    }
    if (found < kMaxSessions) {
        sessionActive_[found]   = false;
        configBroadcast_[found] = false; /* allow re-broadcast on slot reuse */
        numSessions_--;
    }
    sessionsMutex_.FastUnLock();

    if (found == kMaxSessions) { return; }

    sessions_[found].Stop();

    REPORT_ERROR_STATIC(MARTe::ErrorManagement::Information,
                        "StreamHub: removed session '%s'.", id);
    BroadcastSources();
}

void StreamHub::HandleGetSources() {
    BroadcastSources();
}

void StreamHub::HandleGetConfig(const char *json) {
    char id[64] = "";
    JsonGetString(json, "sourceId", id, sizeof(id));

    for (uint32 i = 0u; i < kMaxSessions; i++) {
        if (!sessionActive_[i]) { continue; }
        StreamString sid = sessions_[i].GetId();
        if (strcmp(sid.Buffer(), id) == 0) {
            BroadcastConfig(i);
            return;
        }
    }
}

void StreamHub::HandleGetStats() {
    PushStats();
}

void StreamHub::HandleArm() {
    rearmPending_ = false;
    trigger_.Arm();
    BroadcastTriggerState();
}

void StreamHub::HandleDisarm() {
    rearmPending_ = false;
    trigger_.Disarm();
    BroadcastTriggerState();
}

void StreamHub::HandleRearm() {
    /* Single-mode manual rearm (TRIGGERED → ARMED); same as Arm. */
    HandleArm();
}

void StreamHub::HandleTrigStop(const char *json) {
    /* {"type":"trigStop","stopped":bool} — absent "stopped" toggles. */
    bool stopped = !trigger_.GetStopped();
    JsonGetBool(json, "stopped", stopped);
    trigger_.SetStopped(stopped);
    if (stopped) { rearmPending_ = false; }
    BroadcastTriggerState();
}

void StreamHub::HandleSetTrigger(const char *json) {
    /* Web client shape:
     * {"type":"setTrigger","signal":"src:sig[i]","edge":"rising|falling|both",
     *  "threshold":F,"windowSec":F,"prePercent":F,"mode":"normal|single"} */
    TriggerConfig cfg = trigger_.GetConfig();

    char key[160]   = "";
    char edge[16]   = "";
    char mode[16]   = "";
    float64 thr     = cfg.threshold;
    float64 winSec  = cfg.windowSec;
    float64 prePct  = cfg.prePercent;

    if (JsonGetString(json, "signal", key, sizeof(key))) {
        cfg.signalKey = key;
    }
    if (JsonGetString(json, "edge", edge, sizeof(edge))) {
        if      (strcmp(edge, "falling") == 0) { cfg.edge = kEdgeFalling; }
        else if (strcmp(edge, "both")    == 0) { cfg.edge = kEdgeBoth;    }
        else                                   { cfg.edge = kEdgeRising;  }
    }
    if (JsonGetString(json, "mode", mode, sizeof(mode))) {
        cfg.mode = (strcmp(mode, "single") == 0) ? kTrigSingle : kTrigNormal;
    }
    if (JsonGetFloat(json, "threshold",  thr))    { cfg.threshold  = thr;    }
    if (JsonGetFloat(json, "windowSec",  winSec)) { cfg.windowSec  = winSec; }
    if (JsonGetFloat(json, "prePercent", prePct)) { cfg.prePercent = prePct; }

    trigger_.SetConfig(cfg);
    BroadcastTriggerState();
}

/*---------------------------------------------------------------------------*/
/* Trigger servicing (push thread)                                            */
/*---------------------------------------------------------------------------*/

void StreamHub::TriggerTick(float64 wallNowS) {
    /* Capture-margin: wait a little past the post window so the rings have
     * received the last post-trigger samples (web client used 120 ms). */
    static const float64 kCaptureMarginS = 0.15;
    static const float64 kAutoRearmDelayS = 0.2;

    const TrigState st = trigger_.GetState();

    if (st == kTrigCollecting) {
        float64 trigTime = 0.0;
        float64 preSec   = 0.0;
        float64 postSec  = 0.0;
        if (trigger_.GetFiredWindow(trigTime, preSec, postSec) &&
            (wallNowS >= (trigTime + postSec + kCaptureMarginS))) {
            BroadcastTriggerCapture(trigTime, preSec, postSec);
            trigger_.MarkTriggered();
            TriggerConfig cfg = trigger_.GetConfig();
            if ((cfg.mode == kTrigNormal) && !trigger_.GetStopped()) {
                rearmPending_ = true;
                rearmAtWallS_ = wallNowS + kAutoRearmDelayS;
            }
        }
    }
    else if ((st == kTrigTriggered) && rearmPending_ &&
             (wallNowS >= rearmAtWallS_)) {
        rearmPending_ = false;
        if (!trigger_.GetStopped()) {
            trigger_.Arm();
        }
    }

    const TrigState cur = trigger_.GetState();
    if (cur != lastTrigState_) {
        lastTrigState_ = cur;
        BroadcastTriggerState();
    }
}

void StreamHub::BroadcastTriggerState() {
    const TrigState st = trigger_.GetState();
    TriggerConfig  cfg = trigger_.GetConfig();
    const bool stopped = trigger_.GetStopped();
    lastTrigState_ = st;

    const char *stateStr =
        (st == kTrigArmed)      ? "armed" :
        (st == kTrigCollecting) ? "collecting" :
        (st == kTrigTriggered)  ? "triggered" : "idle";
    const char *modeStr = (cfg.mode == kTrigSingle) ? "single" : "normal";

    char buf[256];
    int n;
    float64 trigTime = 0.0;
    float64 preSec   = 0.0;
    float64 postSec  = 0.0;
    if (((st == kTrigCollecting) || (st == kTrigTriggered)) &&
        trigger_.GetFiredWindow(trigTime, preSec, postSec)) {
        n = snprintf(buf, sizeof(buf),
            "{\"type\":\"triggerState\",\"state\":\"%s\",\"mode\":\"%s\","
            "\"stopped\":%s,\"trigTime\":%.17g}",
            stateStr, modeStr, (stopped ? "true" : "false"), trigTime);
    } else {
        n = snprintf(buf, sizeof(buf),
            "{\"type\":\"triggerState\",\"state\":\"%s\",\"mode\":\"%s\","
            "\"stopped\":%s}",
            stateStr, modeStr, (stopped ? "true" : "false"));
    }
    if (n > 0) {
        wsServer_.BroadcastText(buf, static_cast<uint32>(n));
    }
}

void StreamHub::BroadcastTriggerCapture(float64 trigTime, float64 preSec,
                                        float64 postSec) {
    const float64 t0 = trigTime - preSec;
    const float64 t1 = trigTime + postSec;

    /* Read scratch sized for the largest ring; LTTB scratch for the cap. */
    const uint32 scratchCap = (ringTemporal_ > ringScalar_) ? ringTemporal_
                                                            : ringScalar_;
    float64 *tRaw = new float64[scratchCap];
    float64 *vRaw = new float64[scratchCap];
    float64 *tDec = new float64[kTrigCapturePts];
    float64 *vDec = new float64[kTrigCapturePts];

    uint32 cap = 1u << 20;
    uint8 *buf = new uint8[cap];
    uint32 off = 0u;

    /* Header: [u8 2][f64 trigTime][f64 preSec][f64 postSec][u32 nSig] */
    buf[off++] = 2u;
    memcpy(buf + off, &trigTime, 8u); off += 8u;
    memcpy(buf + off, &preSec,   8u); off += 8u;
    memcpy(buf + off, &postSec,  8u); off += 8u;
    const uint32 nSigOff = off;
    uint32 nSigWritten = 0u;
    off += 4u;

    for (uint32 i = 0u; i < kMaxSessions; i++) {
        if (!sessionActive_[i]) { continue; }
        UDPSourceSession &sess = sessions_[i];
        if (!sess.IsConfigured()) { continue; }

        StreamString sid = sess.GetId();
        const uint32 numSigs = sess.GetNumSignals();

        for (uint32 s = 0u; s < numSigs; s++) {
            MARTe::UDPSSignalDescriptor desc;
            if (!sess.GetSignalDescriptor(s, desc)) { continue; }

            const uint32 nRaw = sess.ReadSignalRange(s, t0, t1,
                                                     tRaw, vRaw, scratchCap);
            if (nRaw == 0u) { continue; }

            const float64 *tOut = tRaw;
            const float64 *vOut = vRaw;
            uint32 nOut = nRaw;
            if (nRaw > kTrigCapturePts) {
                nOut = LTTBDecimate(tRaw, vRaw, nRaw, tDec, vDec,
                                    kTrigCapturePts);
                tOut = tDec;
                vOut = vDec;
            }

            char fullKey[192];
            const int kn = snprintf(fullKey, sizeof(fullKey), "%s:%s",
                                    sid.Buffer(), desc.name);
            if (kn <= 0) { continue; }
            const uint32 keyLen = static_cast<uint32>(kn);

            const uint32 need = 2u + keyLen + 4u + nOut * 16u;
            if ((off + need) > cap) {
                uint32 newCap = cap * 2u;
                while ((off + need) > newCap) { newCap *= 2u; }
                uint8 *nb = new uint8[newCap];
                memcpy(nb, buf, off);
                delete[] buf;
                buf = nb;
                cap = newCap;
            }

            buf[off++] = static_cast<uint8>( keyLen       & 0xFFu);
            buf[off++] = static_cast<uint8>((keyLen >> 8) & 0xFFu);
            memcpy(buf + off, fullKey, keyLen);
            off += keyLen;

            buf[off++] = static_cast<uint8>( nOut        & 0xFFu);
            buf[off++] = static_cast<uint8>((nOut >>  8) & 0xFFu);
            buf[off++] = static_cast<uint8>((nOut >> 16) & 0xFFu);
            buf[off++] = static_cast<uint8>((nOut >> 24) & 0xFFu);

            memcpy(buf + off, tOut, nOut * sizeof(float64));
            off += nOut * 8u;
            memcpy(buf + off, vOut, nOut * sizeof(float64));
            off += nOut * 8u;

            nSigWritten++;
        }
    }

    /* Patch nSig */
    buf[nSigOff]      = static_cast<uint8>( nSigWritten        & 0xFFu);
    buf[nSigOff + 1u] = static_cast<uint8>((nSigWritten >>  8) & 0xFFu);
    buf[nSigOff + 2u] = static_cast<uint8>((nSigWritten >> 16) & 0xFFu);
    buf[nSigOff + 3u] = static_cast<uint8>((nSigWritten >> 24) & 0xFFu);

    wsServer_.BroadcastBinary(buf, off);

    REPORT_ERROR_STATIC(MARTe::ErrorManagement::Information,
        "StreamHub: trigger capture broadcast (%u signal(s), %u bytes).",
        nSigWritten, off);

    delete[] buf;
    delete[] tRaw; delete[] vRaw;
    delete[] tDec; delete[] vDec;
}

void StreamHub::HandleZoom(const char *json, uint32 slotIdx) {
    /* Request : {"type":"zoom","reqId":N,"t0":F,"t1":F,"n":N,
     *            "signals":"src:sig,src:sig2"}
     * Response: {"type":"zoom","reqId":N,
     *            "signals":{"src:sig":{"t":[...],"v":[...]},...}}
     * Sent unicast to the requesting client (Go hub /api/zoom shape). */
    float64 t0 = 0.0, t1 = 0.0;
    float64 nF = 2400.0;
    float64 reqIdF = 0.0;

    JsonGetFloat(json, "t0",    t0);
    JsonGetFloat(json, "t1",    t1);
    JsonGetFloat(json, "reqId", reqIdF);
    const bool haveN = JsonGetFloat(json, "n", nF);

    /* n semantics (Go hub): missing → 2400; ≤0 → no decimation; <10 → 2400 */
    uint32 maxOut = 2400u;
    if (haveN) {
        if (nF <= 0.0)      { maxOut = 0u; /* 0 = no decimation */ }
        else if (nF < 10.0) { maxOut = 2400u; }
        else                { maxOut = static_cast<uint32>(nF); }
    }

    /* Comma-separated full keys "src:sig" */
    static const uint32 kKeysBuf = 8192u;
    char *keys = new char[kKeysBuf];
    keys[0] = '\0';
    JsonGetString(json, "signals", keys, kKeysBuf);

    /* Read scratch sized for the largest possible ring (no double decimation:
     * the whole [t0,t1] slice is read, then LTTB'd once to maxOut). */
    const uint32 scratchCap = (ringTemporal_ > ringScalar_) ? ringTemporal_
                                                            : ringScalar_;
    float64 *tRaw = new float64[scratchCap];
    float64 *vRaw = new float64[scratchCap];
    float64 *tDec = (maxOut > 0u) ? new float64[maxOut] : static_cast<float64 *>(0);
    float64 *vDec = (maxOut > 0u) ? new float64[maxOut] : static_cast<float64 *>(0);

    uint32 cap = 65536u;
    char  *buf = new char[cap];
    uint32 off = 0u;

    JsonAppendf(buf, off, cap, "{\"type\":\"zoom\",\"reqId\":%u,\"signals\":{",
                static_cast<uint32>(reqIdF));

    bool first = true;
    char *tok = keys;
    while ((tok != static_cast<char *>(0)) && (*tok != '\0') && (t1 > t0)) {
        char *next = strchr(tok, ',');
        if (next != static_cast<char *>(0)) { *next = '\0'; next++; }
        while (*tok == ' ') { tok++; }

        char *colon = strchr(tok, ':');
        if (colon == static_cast<char *>(0)) { tok = next; continue; }
        *colon = '\0';
        const char *srcId   = tok;
        const char *sigName = colon + 1;

        for (uint32 i = 0u; i < kMaxSessions; i++) {
            if (!sessionActive_[i]) { continue; }
            StreamString sid = sessions_[i].GetId();
            if (strcmp(sid.Buffer(), srcId) != 0) { continue; }

            UDPSourceSession &sess = sessions_[i];
            const uint32 numSigs = sess.GetNumSignals();
            for (uint32 s = 0u; s < numSigs; s++) {
                MARTe::UDPSSignalDescriptor desc;
                if (!sess.GetSignalDescriptor(s, desc)) { continue; }
                if (strcmp(desc.name, sigName) != 0) { continue; }

                const uint32 nRaw = sess.ReadSignalRange(s, t0, t1,
                                                         tRaw, vRaw, scratchCap);
                if (nRaw == 0u) { break; }

                const float64 *tOut = tRaw;
                const float64 *vOut = vRaw;
                uint32 nOut = nRaw;
                if ((maxOut > 0u) && (nRaw > maxOut)) {
                    nOut = LTTBDecimate(tRaw, vRaw, nRaw, tDec, vDec, maxOut);
                    tOut = tDec;
                    vOut = vDec;
                }

                /* %.17g keeps full float64 precision for Unix-epoch times;
                 * %.9g is plenty for float32-quality values. */
                JsonAppendf(buf, off, cap, "%s\"%s:%s\":{\"t\":[",
                            (first ? "" : ","), srcId, sigName);
                first = false;
                for (uint32 p = 0u; p < nOut; p++) {
                    JsonAppendf(buf, off, cap, "%s%.17g",
                                (p > 0u ? "," : ""), tOut[p]);
                }
                JsonAppendf(buf, off, cap, "],\"v\":[");
                for (uint32 p = 0u; p < nOut; p++) {
                    JsonAppendf(buf, off, cap, "%s%.9g",
                                (p > 0u ? "," : ""), vOut[p]);
                }
                JsonAppendf(buf, off, cap, "]}");
                break;
            }
            break;
        }
        tok = next;
    }

    JsonAppendf(buf, off, cap, "}}");
    wsServer_.SendText(slotIdx, buf, off);

    delete[] buf;
    delete[] keys;
    delete[] tRaw; delete[] vRaw;
    delete[] tDec; delete[] vDec;
}

void StreamHub::HandleHistoryZoom(const char *json, uint32 slotIdx) {
    if (!history_.IsEnabled()) {
        const char *msg = "{\"type\":\"historyZoom\",\"error\":\"history not enabled\"}";
        wsServer_.SendText(slotIdx, msg, static_cast<uint32>(strlen(msg)));
        return;
    }

    float64 t0 = 0.0, t1 = 0.0;
    float64 nF = 2400.0;
    float64 reqIdF = 0.0;

    JsonGetFloat(json, "t0",    t0);
    JsonGetFloat(json, "t1",    t1);
    JsonGetFloat(json, "reqId", reqIdF);
    const bool haveN = JsonGetFloat(json, "n", nF);

    uint32 maxOut = 2400u;
    if (haveN) {
        if (nF <= 0.0)      { maxOut = 0u; }
        else if (nF < 10.0) { maxOut = 2400u; }
        else                { maxOut = static_cast<uint32>(nF); }
    }

    /* Parse comma-separated full keys "src:sig" */
    static const uint32 kKeysBuf = 8192u;
    char *keys = new char[kKeysBuf];
    keys[0] = '\0';
    JsonGetString(json, "signals", keys, kKeysBuf);

    /* Allocate scratch for the largest possible read */
    const uint32 scratchCap = 2000000u; /* 2M pts should be enough */
    float64 *tRaw = new float64[scratchCap];
    float64 *vRaw = new float64[scratchCap];
    float64 *tDec = (maxOut > 0u) ? new float64[maxOut] : static_cast<float64 *>(0);
    float64 *vDec = (maxOut > 0u) ? new float64[maxOut] : static_cast<float64 *>(0);

    uint32 cap = 65536u;
    char  *buf = new char[cap];
    uint32 off = 0u;

    JsonAppendf(buf, off, cap, "{\"type\":\"historyZoom\",\"reqId\":%u,\"signals\":{",
                static_cast<uint32>(reqIdF));

    bool first = true;
    char *tok = keys;
    while ((tok != static_cast<char *>(0)) && (*tok != '\0') && (t1 > t0)) {
        char *next = strchr(tok, ',');
        if (next != static_cast<char *>(0)) { *next = '\0'; next++; }
        while (*tok == ' ') { tok++; }

        char *colon = strchr(tok, ':');
        if (colon == static_cast<char *>(0)) { tok = next; continue; }
        *colon = '\0';
        const char *srcId   = tok;
        const char *sigName = colon + 1;

        uint32 nRaw = history_.ReadRange(srcId, sigName, t0, t1,
                                         tRaw, vRaw, scratchCap);
        if (nRaw > 0u) {
            const float64 *tOut = tRaw;
            const float64 *vOut = vRaw;
            uint32 nOut = nRaw;
            if ((maxOut > 0u) && (nRaw > maxOut)) {
                nOut = LTTBDecimate(tRaw, vRaw, nRaw, tDec, vDec, maxOut);
                tOut = tDec;
                vOut = vDec;
            }

            JsonAppendf(buf, off, cap, "%s\"%s:%s\":{\"t\":[",
                        (first ? "" : ","), srcId, sigName);
            first = false;
            for (uint32 p = 0u; p < nOut; p++) {
                JsonAppendf(buf, off, cap, "%s%.17g",
                            (p > 0u ? "," : ""), tOut[p]);
            }
            JsonAppendf(buf, off, cap, "],\"v\":[");
            for (uint32 p = 0u; p < nOut; p++) {
                JsonAppendf(buf, off, cap, "%s%.9g",
                            (p > 0u ? "," : ""), vOut[p]);
            }
            JsonAppendf(buf, off, cap, "]}");
        }
        tok = next;
    }

    JsonAppendf(buf, off, cap, "}}");
    wsServer_.SendText(slotIdx, buf, off);

    delete[] buf;
    delete[] keys;
    delete[] tRaw; delete[] vRaw;
    delete[] tDec; delete[] vDec;
}

void StreamHub::HandleHistoryInfo(uint32 slotIdx) {
    uint32 cap = 8192u;
    char  *buf = new char[cap];
    uint32 off = 0u;

    JsonAppendf(buf, off, cap, "{\"type\":\"historyInfo\",");
    history_.AppendInfoJSON(buf, off, cap);
    JsonAppendf(buf, off, cap, "}");

    wsServer_.SendText(slotIdx, buf, off);
    delete[] buf;
}

void StreamHub::HandleSetMaxPoints(const char *json) {
    float64 mpF = static_cast<float64>(maxPoints_);
    JsonGetFloat(json, "maxPoints", mpF);
    uint32 newMax = static_cast<uint32>(mpF);
    if (newMax < 2u) { newMax = 2u; }

    /* Defer to the push loop: the WS read thread must not mutate session
     * read state while PushData is iterating.  The push loop applies the
     * value and broadcasts "maxPointsUpdated". */
    pendingMaxPoints_    = newMax;
    pendingMaxPointsSet_ = true;
}

void StreamHub::HandlePing(uint32 slotIdx) {
    const char *msg = "{\"type\":\"pong\"}";
    wsServer_.SendText(slotIdx, msg, static_cast<uint32>(strlen(msg)));
}

/*---------------------------------------------------------------------------*/
/* Binary recorder commands                                                   */
/*---------------------------------------------------------------------------*/

void StreamHub::HandleRecStart(const char *json) {
    char wantId[64] = "";
    const bool filtered = JsonGetString(json, "sourceId", wantId, sizeof(wantId));
    for (uint32 i = 0u; i < kMaxSessions; i++) {
        if (!sessionActive_[i]) { continue; }
        if (!sessions_[i].IsRecorderEnabled()) { continue; }
        if (filtered) {
            StreamString sid = sessions_[i].GetId();
            if (strcmp(sid.Buffer(), wantId) != 0) { continue; }
        }
        sessions_[i].RequestRecArm();
    }
    BroadcastRecStatus();
}

void StreamHub::HandleRecStop(const char *json) {
    char wantId[64] = "";
    const bool filtered = JsonGetString(json, "sourceId", wantId, sizeof(wantId));
    for (uint32 i = 0u; i < kMaxSessions; i++) {
        if (!sessionActive_[i]) { continue; }
        if (!sessions_[i].IsRecorderEnabled()) { continue; }
        if (filtered) {
            StreamString sid = sessions_[i].GetId();
            if (strcmp(sid.Buffer(), wantId) != 0) { continue; }
        }
        sessions_[i].RequestRecDisarm();
    }
    BroadcastRecStatus();
}

void StreamHub::AppendRecStatusJSON(char *&buf, uint32 &off, uint32 &cap) {
    JsonAppendf(buf, off, cap, "{\"type\":\"recStatus\",\"sources\":{");
    bool first = true;
    for (uint32 i = 0u; i < kMaxSessions; i++) {
        if (!sessionActive_[i]) { continue; }
        if (!sessions_[i].IsRecorderEnabled()) { continue; }

        bool   recording   = false;
        char   file[768]   = "";
        uint64 bytesWritten = 0u;
        uint64 rowsWritten  = 0u;
        uint64 droppedRows  = 0u;
        uint64 freeMB       = 0u;
        sessions_[i].GetRecorderInfo(recording, file, sizeof(file),
                                     bytesWritten, rowsWritten,
                                     droppedRows, freeMB);

        StreamString sid = sessions_[i].GetId();
        JsonAppendf(buf, off, cap, "%s\"%s\":{\"recording\":%s,\"file\":\"%s\","
                    "\"bytesWritten\":%llu,\"rowsWritten\":%llu,"
                    "\"droppedRows\":%llu,\"freeMB\":%llu}",
                    first ? "" : ",", sid.Buffer(),
                    recording ? "true" : "false", file,
                    static_cast<unsigned long long>(bytesWritten),
                    static_cast<unsigned long long>(rowsWritten),
                    static_cast<unsigned long long>(droppedRows),
                    static_cast<unsigned long long>(freeMB));
        first = false;
    }
    JsonAppendf(buf, off, cap, "}}");
}

void StreamHub::HandleRecInfo(uint32 slotIdx) {
    uint32 cap = 4096u;
    char  *buf = new char[cap];
    uint32 off = 0u;
    AppendRecStatusJSON(buf, off, cap);
    wsServer_.SendText(slotIdx, buf, off);
    delete[] buf;
}

void StreamHub::BroadcastRecStatus() {
    uint32 cap = 4096u;
    char  *buf = new char[cap];
    uint32 off = 0u;
    AppendRecStatusJSON(buf, off, cap);
    wsServer_.BroadcastText(buf, off);
    delete[] buf;
}

/*---------------------------------------------------------------------------*/
/* Tiny JSON helpers                                                          */
/*---------------------------------------------------------------------------*/

bool StreamHub::JsonGetString(const char *json, const char *key,
                               char *out, uint32 outSize) {
    /* Look for "key":"value" */
    char pattern[128];
    snprintf(pattern, sizeof(pattern), "\"%s\":\"", key);
    const char *p = strstr(json, pattern);
    if (p == static_cast<const char *>(0)) { return false; }
    p += strlen(pattern);

    uint32 i = 0u;
    while (*p != '\0' && *p != '"' && i < outSize - 1u) {
        out[i++] = *p++;
    }
    out[i] = '\0';
    return true;
}

bool StreamHub::JsonGetFloat(const char *json, const char *key, float64 &out) {
    char pattern[128];
    snprintf(pattern, sizeof(pattern), "\"%s\":", key);
    const char *p = strstr(json, pattern);
    if (p == static_cast<const char *>(0)) { return false; }
    p += strlen(pattern);
    while (*p == ' ') { p++; }
    if (*p == '\0') { return false; }
    out = strtod(p, static_cast<char **>(0));
    return true;
}

bool StreamHub::JsonGetUint32(const char *json, const char *key, uint32 &out) {
    float64 v = 0.0;
    if (!JsonGetFloat(json, key, v)) { return false; }
    out = static_cast<uint32>(v);
    return true;
}

bool StreamHub::JsonGetBool(const char *json, const char *key, bool &out) {
    char pattern[128];
    snprintf(pattern, sizeof(pattern), "\"%s\":", key);
    const char *p = strstr(json, pattern);
    if (p == static_cast<const char *>(0)) { return false; }
    p += strlen(pattern);
    while (*p == ' ') { p++; }
    if (strncmp(p, "true", 4u) == 0) {
        out = true;
        return true;
    }
    if (strncmp(p, "false", 5u) == 0) {
        out = false;
        return true;
    }
    return false;
}

} /* namespace StreamHub */
