using Google.Protobuf;
using Org.Eclipse.Tahu.Protobuf;

namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;

/// <summary>
///     Builds the two Sparkplug-B wire vectors committed under <c>Golden/</c> — an NBIRTH and the
///     NDATA that follows it — and names the files they live in.
/// </summary>
/// <remarks>
///     <para>
///         <b>Why a generator instead of bytes captured off a real edge node.</b> A capture would pin
///         one vendor's encoder rather than the schema, could not be regenerated when the vendored
///         proto is re-vendored, and would drag an unaudited third-party payload into the repo. These
///         payloads are hand-built from the generated types, serialized once, and committed; the
///         <c>SparkplugGoldenVectorTests</c> drift guard re-runs this builder on every test run and
///         fails if the committed bytes no longer match, which is what makes the files an actual
///         regression pin rather than decoration.
///     </para>
///     <para>
///         <b>Every field in here is load-bearing.</b> The NBIRTH carries <c>seq = 0</c> (present and
///         zero — the case a <c>Seq != 0</c> presence check gets wrong), a negative Int32 encoded the
///         Sparkplug way (two's complement in an unsigned <c>int_value</c>), an explicit
///         <c>is_null</c> metric, and a <c>DataSet</c> metric that v1 does not support. The NDATA
///         carries metrics with <b>no name and no datatype</b> — alias only — which is how every real
///         Sparkplug DATA message after a birth looks, and the case an "absent means empty string"
///         decoder gets wrong. Do not "tidy" these payloads.
///     </para>
/// </remarks>
internal static class SparkplugGoldenPayloads
{
    /// <summary>Repo-relative directory the committed vectors live in, inside the test project.</summary>
    public const string Directory = "Golden";

    /// <summary>File name of the NBIRTH vector.</summary>
    public const string NbirthFile = "nbirth.bin";

    /// <summary>File name of the NDATA vector.</summary>
    public const string NdataFile = "ndata.bin";

    /// <summary>NBIRTH payload timestamp — 2024-07-24T12:00:00Z, in Sparkplug's epoch milliseconds.</summary>
    public const ulong NbirthTimestampMs = 1721822400000UL;

    /// <summary>NDATA payload timestamp — five seconds after the birth.</summary>
    public const ulong NdataTimestampMs = 1721822405000UL;

    /// <summary>The alias the NBIRTH binds to <c>Temperature</c>, reused by the NDATA.</summary>
    public const ulong TemperatureAlias = 5UL;

    /// <summary>The alias the NBIRTH binds to <c>Count</c> — the negative-Int32 case.</summary>
    public const ulong CountAlias = 9UL;

    /// <summary>The alias the NBIRTH binds to <c>Missing</c> — the explicit-null case.</summary>
    public const ulong MissingAlias = 11UL;

    /// <summary>The <c>Count</c> metric's value in the NBIRTH — negative, to pin the wire encoding.</summary>
    public const int CountBirthValue = -42;

    /// <summary>The <c>Count</c> metric's value in the NDATA.</summary>
    public const int CountDataValue = -43;

    /// <summary>
    ///     Builds the NBIRTH: <c>seq = 0</c>, a full metric catalog with names, aliases and datatypes.
    /// </summary>
    /// <returns>The payload; serialize with <see cref="MessageExtensions.ToByteArray"/>.</returns>
    public static Payload BuildNbirth()
    {
        var payload = new Payload
        {
            Timestamp = NbirthTimestampMs,

            // Present AND zero. Sparkplug REQUIRES an NBIRTH to carry seq = 0, so any decoder that
            // infers absence from a zero value reports "no sequence" for every birth it ever sees.
            Seq = 0UL,
        };

        payload.Metrics.Add(new Payload.Types.Metric
        {
            Name = "bdSeq",
            Datatype = (uint)DataType.Uint64,
            LongValue = 7UL,
        });

        payload.Metrics.Add(new Payload.Types.Metric
        {
            Name = "Node Control/Rebirth",
            Datatype = (uint)DataType.Boolean,
            BooleanValue = false,
        });

        payload.Metrics.Add(new Payload.Types.Metric
        {
            Name = "Temperature",
            Alias = TemperatureAlias,
            Timestamp = NbirthTimestampMs,
            Datatype = (uint)DataType.Float,
            FloatValue = 21.5f,
        });

        payload.Metrics.Add(new Payload.Types.Metric
        {
            Name = "Pressure",
            Alias = 6UL,
            Datatype = (uint)DataType.Double,
            DoubleValue = 101.325d,
        });

        payload.Metrics.Add(new Payload.Types.Metric
        {
            Name = "Running",
            Alias = 7UL,
            Datatype = (uint)DataType.Boolean,
            BooleanValue = true,
        });

        payload.Metrics.Add(new Payload.Types.Metric
        {
            Name = "Serial",
            Alias = 8UL,
            Datatype = (uint)DataType.String,
            StringValue = "EDGE-A-001",
        });

        // Sparkplug carries every signed integer up to 32 bits in the UNSIGNED int_value field as
        // two's complement, so -42 goes on the wire as 4294967254. A decoder that hands the raw
        // uint32 straight to a consumer publishes 4294967254 for a tag whose value is -42.
        payload.Metrics.Add(new Payload.Types.Metric
        {
            Name = "Count",
            Alias = CountAlias,
            Datatype = (uint)DataType.Int32,
            IntValue = unchecked((uint)CountBirthValue),
        });

        // DataSet is explicitly out of scope for v1 — present here so "unsupported" is a decoded,
        // testable outcome rather than an exception on the MQTT dispatcher thread.
        var dataSet = new Payload.Types.DataSet { NumOfColumns = 1UL };
        dataSet.Columns.Add("col0");
        dataSet.Types_.Add((uint)DataType.Int32);
        var row = new Payload.Types.DataSet.Types.Row();
        row.Elements.Add(new Payload.Types.DataSet.Types.DataSetValue { IntValue = 1U });
        dataSet.Rows.Add(row);

        payload.Metrics.Add(new Payload.Types.Metric
        {
            Name = "Config",
            Alias = 10UL,
            Datatype = (uint)DataType.DataSet,
            DatasetValue = dataSet,
        });

        // is_null = true with NO value in the oneof: "this metric exists and its value is null",
        // which is a different fact from "this metric carried no value field".
        payload.Metrics.Add(new Payload.Types.Metric
        {
            Name = "Missing",
            Alias = MissingAlias,
            Datatype = (uint)DataType.Float,
            IsNull = true,
        });

        return payload;
    }

    /// <summary>
    ///     Builds the NDATA that follows <see cref="BuildNbirth"/>: <c>seq = 1</c> and metrics carrying
    ///     <b>alias only</b> — no name, no datatype — exactly as a real edge node publishes them.
    /// </summary>
    /// <returns>The payload; serialize with <see cref="MessageExtensions.ToByteArray"/>.</returns>
    public static Payload BuildNdata()
    {
        var payload = new Payload
        {
            Timestamp = NdataTimestampMs,
            Seq = 1UL,
        };

        payload.Metrics.Add(new Payload.Types.Metric
        {
            Alias = TemperatureAlias,
            Timestamp = NdataTimestampMs,
            FloatValue = 22.25f,
        });

        payload.Metrics.Add(new Payload.Types.Metric
        {
            Alias = CountAlias,
            IntValue = unchecked((uint)CountDataValue),
        });

        payload.Metrics.Add(new Payload.Types.Metric
        {
            Alias = MissingAlias,
            IsNull = true,
        });

        return payload;
    }

    /// <summary>Reads a committed vector from the test binary's output directory.</summary>
    /// <param name="fileName"><see cref="NbirthFile"/> or <see cref="NdataFile"/>.</param>
    /// <returns>The committed wire bytes.</returns>
    public static byte[] Read(string fileName) => File.ReadAllBytes(PathTo(fileName));

    /// <summary>Resolves a vector's path under the test binary's output directory.</summary>
    /// <param name="fileName"><see cref="NbirthFile"/> or <see cref="NdataFile"/>.</param>
    /// <returns>The absolute path.</returns>
    /// <remarks>
    ///     Rooted at <see cref="AppContext.BaseDirectory"/> rather than the process working directory:
    ///     the runner's cwd is not guaranteed to be the output folder, and a relative read that
    ///     happens to work under one runner is exactly how a fixture stops being copied without
    ///     anyone noticing.
    /// </remarks>
    public static string PathTo(string fileName) =>
        Path.Combine(AppContext.BaseDirectory, Directory, fileName);
}
