using System.Text.Json;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;

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

/// <summary>
///     v3 pure mapper: turns an authored raw tag's <c>TagConfig</c> JSON (the shape produced by the
///     AdminUI <c>MqttTagConfigModel</c>) into an <see cref="MqttTagDefinition"/>. Under v3 a tag's
///     identity is its <b>RawPath</b> (a cluster-scoped slash path), not the address blob — so the
///     produced definition's <see cref="MqttTagDefinition.Name"/> is the RawPath the driver was handed,
///     which is exactly the wire reference the driver's <c>RawPath → def</c> resolver keys on. The driver
///     builds that table by mapping each <see cref="RawTagEntry"/> the deploy artifact delivers through
///     <see cref="FromTagConfig"/>.
///     <para>
///         Two entry points carry deliberately different strictness contracts.
///         <see cref="FromTagConfig"/> is the <b>runtime</b> path: it never throws, and anything
///         malformed returns <see langword="false"/> so the driver surfaces <c>BadNodeIdUnknown</c>
///         rather than a misleading <c>Good</c> off a wrong-typed default. <see cref="Inspect"/> is the
///         <b>deploy-time</b> path: it returns human-readable warnings so a bad tag config surfaces at
///         deploy instead of silently going dark at runtime.
///     </para>
///     <para>
///         <b>Two runtime entry points, one per ingest shape</b> — <see cref="FromTagConfig"/> (Plain)
///         and <see cref="FromSparkplugTagConfig"/> (Sparkplug B). The caller picks by the driver's
///         <see cref="MqttMode"/>; neither sniffs the blob. That is deliberate: the AdminUI editor
///         preserves unknown keys through a load→save, so a tag retyped from Plain to Sparkplug keeps
///         its old <c>topic</c> and a presence heuristic would make one authored blob mean two
///         different things depending on which key happened to survive. The <i>driver's</i> mode is
///         the single authority.
///     </para>
///     <para>
///         <b>No <c>ToTagConfig</c> inverse — a deliberate YAGNI call.</b> The six sibling factories
///         each carry one solely to serve their <c>Driver.&lt;X&gt;.Cli</c> project, which synthesises
///         <see cref="RawTagEntry"/> blobs from operator flags; the MQTT plan defines no
///         <c>Mqtt.Cli</c>. The AdminUI typed editor does not need one either — the
///         <c>&lt;Driver&gt;TagConfigModel</c> template serialises through its own preserved
///         <c>JsonObject</c> key bag and references no driver factory (verified: no
///         <c>TagDefinitionFactory</c> reference exists anywhere under the AdminUI project). Add the
///         inverse when a real caller appears, not before.
///     </para>
/// </summary>
public static class MqttTagDefinitionFactory
{
    /// <summary>The JSONPath applied when a Json-format blob omits <c>jsonPath</c>: the document root.</summary>
    private const string RootJsonPath = "$";

    /// <summary>The MQTT wildcard characters — illegal in a <em>tag's</em> (concrete) subscription topic.</summary>
    private static readonly char[] TopicWildcards = ['+', '#'];

    /// <summary>
    ///     Maps an authored <c>TagConfig</c> object to a typed definition keyed by <paramref name="rawPath"/>.
    ///     The input is always an authored TagConfig JSON object (there is no name-vs-blob heuristic in v3).
    ///     Enum fields (<c>payloadFormat</c> / <c>dataType</c>) and <c>qos</c> are read STRICTLY — a
    ///     present-but-invalid (typo'd) value rejects the whole tag (returns <see langword="false"/> ⇒ the
    ///     driver surfaces <c>BadNodeIdUnknown</c>) rather than silently defaulting to a wrong-typed Good or
    ///     a downgraded delivery guarantee. Never throws.
    ///     <para>
    ///         A <b>wildcard</b> topic (<c>+</c> / <c>#</c>) is deliberately NOT rejected here: it is
    ///         ambiguous rather than unparseable, and <see cref="Inspect"/> is the operator-visible surface
    ///         for it. Rejecting it at runtime would turn an authoring mistake into a silent
    ///         <c>BadNodeIdUnknown</c> with no stated cause.
    ///     </para>
    /// </summary>
    /// <param name="tagConfig">The authored equipment-tag TagConfig JSON.</param>
    /// <param name="rawPath">The tag's RawPath — becomes the definition's identity (<c>Name</c>).</param>
    /// <param name="def">The mapped definition when this returns <see langword="true"/>.</param>
    /// <returns><see langword="true"/> when <paramref name="tagConfig"/> is a valid MQTT tag-config object.</returns>
    public static bool FromTagConfig(string tagConfig, string rawPath, out MqttTagDefinition def)
    {
        def = null!;
        if (string.IsNullOrWhiteSpace(tagConfig) || tagConfig[0] != '{') return false;
        try
        {
            using var doc = JsonDocument.Parse(tagConfig);
            var root = doc.RootElement;
            if (root.ValueKind != JsonValueKind.Object) return false;

            // topic is the whole point of a Plain-mode tag — without one there is nothing to subscribe
            // to, so an absent/blank value is a hard reject rather than a defaulted empty subscription.
            var topic = ReadString(root, MqttTagConfigKeys.Topic);
            if (string.IsNullOrWhiteSpace(topic)) return false;

            // Strict enum reads: a present-but-invalid (typo'd) value rejects the whole tag
            // (→ BadNodeIdUnknown) instead of silently defaulting to a wrong-typed Good.
            if (!TagConfigJson.TryReadEnumStrict(root, "payloadFormat", MqttPayloadFormat.Json, out var payloadFormat))
                return false;
            var dataTypeAuthored = root.TryGetProperty("dataType", out _);
            if (!TagConfigJson.TryReadEnumStrict(root, "dataType", DriverDataType.String, out var dataType))
                return false;

            // qos is read with the same strictness, for the same reason: silently absorbing a malformed
            // "qos":"high" / "qos":1.5 / "qos":5 into the driver-level default would hand the operator a
            // WEAKER delivery guarantee than the one they authored, with nothing to show for it.
            if (!TryReadQosStrict(root, out var qos)) return false;

            var jsonPath = ReadString(root, "jsonPath");
            if (string.IsNullOrEmpty(jsonPath)) jsonPath = RootJsonPath;

            def = new MqttTagDefinition(
                Name: rawPath,
                Topic: topic,
                PayloadFormat: payloadFormat,
                JsonPath: jsonPath,
                DataType: dataType,
                Qos: qos,
                RetainSeed: ReadBoolOrDefault(root, "retainSeed", defaultValue: true),
                DataTypeAuthored: dataTypeAuthored);
            return true;
        }
        catch (JsonException) { return false; }
        catch (FormatException) { return false; }
        catch (InvalidOperationException) { return false; }
    }

    /// <summary>
    ///     Maps an authored <c>TagConfig</c> object to a <b>Sparkplug B</b> definition keyed by
    ///     <paramref name="rawPath"/>. The tag's binding identity is the stable
    ///     <c>(groupId, edgeNodeId, deviceId?, metricName)</c> tuple; <c>deviceId</c> is optional (absent
    ///     means the metric is published by the edge node itself), and there is <b>no</b> <c>topic</c> —
    ///     a Sparkplug driver subscribes one group-wide filter and routes by the decoded topic + birth
    ///     certificate, never by a per-tag topic. Never throws.
    /// </summary>
    /// <remarks>
    ///     <para>
    ///         <b><c>dataType</c> is OPTIONAL here, unlike in Plain mode.</b> A Sparkplug metric declares
    ///         its own datatype in its birth certificate, so an unauthored tag legitimately takes the
    ///         type the birth declares — that is the whole point of Task 22's <c>UntilStable</c>
    ///         discovery. It is still read <b>strictly</b> when present (a typo'd value rejects the tag,
    ///         exactly as in Plain mode) and the outcome is recorded on
    ///         <see cref="MqttTagDefinition.DataTypeAuthored"/> so the ingest path can tell "the operator
    ///         declared String" from "nobody declared anything and the record defaulted".
    ///     </para>
    ///     <para>
    ///         <b>Plain-shape keys are read but not required.</b> <c>topic</c>/<c>payloadFormat</c>/
    ///         <c>jsonPath</c> are meaningless to the Sparkplug ingest path and are deliberately NOT
    ///         validated — a blob retyped in the editor keeps them, and rejecting the tag for a stale
    ///         leftover key would take a correctly-authored Sparkplug tag dark with no stated cause.
    ///     </para>
    /// </remarks>
    /// <param name="tagConfig">The authored equipment-tag TagConfig JSON.</param>
    /// <param name="rawPath">The tag's RawPath — becomes the definition's identity (<c>Name</c>).</param>
    /// <param name="def">The mapped definition when this returns <see langword="true"/>.</param>
    /// <returns><see langword="true"/> when the blob carries a usable Sparkplug descriptor.</returns>
    public static bool FromSparkplugTagConfig(string tagConfig, string rawPath, out MqttTagDefinition def)
    {
        def = null!;
        if (string.IsNullOrWhiteSpace(tagConfig) || tagConfig[0] != '{') return false;
        try
        {
            using var doc = JsonDocument.Parse(tagConfig);
            var root = doc.RootElement;
            if (root.ValueKind != JsonValueKind.Object) return false;

            // The binding tuple. Without all three of these there is nothing a birth certificate could
            // ever bind the tag to, so an absent/blank value is a hard reject (→ BadNodeIdUnknown)
            // rather than a definition that can never resolve and reports nothing about why.
            var groupId = ReadString(root, MqttTagConfigKeys.GroupId);
            var edgeNodeId = ReadString(root, MqttTagConfigKeys.EdgeNodeId);
            var metricName = ReadString(root, MqttTagConfigKeys.MetricName);
            if (string.IsNullOrWhiteSpace(groupId)
                || string.IsNullOrWhiteSpace(edgeNodeId)
                || string.IsNullOrWhiteSpace(metricName))
            {
                return false;
            }

            // Optional: a node-level metric has no device segment. Blank normalises to absent so
            // "deviceId":"" and an omitted key describe the same scope rather than two.
            var deviceId = ReadString(root, MqttTagConfigKeys.DeviceId);
            if (string.IsNullOrWhiteSpace(deviceId)) deviceId = null;

            // Strict, but only when present — see the remarks.
            var dataTypeAuthored = root.TryGetProperty("dataType", out _);
            if (!TagConfigJson.TryReadEnumStrict(root, "dataType", DriverDataType.String, out var dataType))
                return false;

            if (!TryReadQosStrict(root, out var qos)) return false;

            def = new MqttTagDefinition(
                Name: rawPath,
                // Plain-shape fields carried through verbatim; the Sparkplug ingest path reads none of
                // them, and a retyped blob's leftovers must not change what the tag means.
                Topic: ReadString(root, MqttTagConfigKeys.Topic),
                PayloadFormat: MqttPayloadFormat.Json,
                JsonPath: RootJsonPath,
                DataType: dataType,
                Qos: qos,
                RetainSeed: ReadBoolOrDefault(root, "retainSeed", defaultValue: true),
                DataTypeAuthored: dataTypeAuthored,
                GroupId: groupId,
                EdgeNodeId: edgeNodeId,
                DeviceId: deviceId,
                MetricName: metricName);
            return true;
        }
        catch (JsonException) { return false; }
        catch (FormatException) { return false; }
        catch (InvalidOperationException) { return false; }
    }

    /// <summary>
    ///     Deploy-time inspection of an equipment-tag <c>TagConfig</c> blob. Returns human-readable
    ///     warnings for a structurally unparseable blob (which the runtime turns into a silent
    ///     <c>BadNodeIdUnknown</c>), for present-but-invalid <c>payloadFormat</c> / <c>dataType</c> /
    ///     <c>qos</c> values, and for a <b>wildcard</b> tag topic (a tag bound to <c>+</c> / <c>#</c>
    ///     would receive values from many topics — ambiguous, and almost never what the operator meant).
    ///     Empty when the blob is clean or is not an equipment-tag TagConfig object. Never throws.
    /// </summary>
    /// <param name="reference">The equipment tag's TagConfig JSON.</param>
    /// <returns>The warnings; empty when clean.</returns>
    public static IReadOnlyList<string> Inspect(string reference)
    {
        var warnings = new List<string>();
        if (string.IsNullOrWhiteSpace(reference) || reference[0] != '{') return warnings;
        try
        {
            using var doc = JsonDocument.Parse(reference);
            var root = doc.RootElement;
            if (root.ValueKind != JsonValueKind.Object)
            {
                warnings.Add("Mqtt TagConfig root is not a JSON object — the tag will not resolve (BadNodeIdUnknown).");
                return warnings;
            }
            foreach (var w in new[]
            {
                TagConfigJson.DescribeInvalidEnum<MqttPayloadFormat>(root, "payloadFormat"),
                TagConfigJson.DescribeInvalidEnum<DriverDataType>(root, "dataType"),
                DescribeInvalidQos(root),
            })
            {
                if (w is not null) warnings.Add(w);
            }
            var topic = ReadString(root, "topic");
            if (topic.IndexOfAny(TopicWildcards) >= 0)
            {
                warnings.Add(
                    $"value '{topic}' for 'topic' contains an MQTT wildcard (+ or #); a tag's topic must be " +
                    "concrete, or the tag will be fed by every matching topic.");
            }
        }
        catch (JsonException)
        {
            warnings.Add("Mqtt TagConfig is not valid JSON — the tag will not resolve (BadNodeIdUnknown).");
        }
        return warnings;
    }

    /// <summary>
    ///     Strict <c>qos</c> read, mirroring <see cref="TagConfigJson.TryReadEnumStrict{TEnum}"/>'s
    ///     absent / valid / present-but-invalid split: absent ⇒ <see langword="null"/> (the driver-level
    ///     <see cref="MqttPlainOptions.DefaultQos"/> wins); a JSON integer in 0–2 ⇒ that value; anything
    ///     else present (non-number, non-integer, or out of range) ⇒ the read FAILS.
    /// </summary>
    /// <param name="o">The TagConfig root object.</param>
    /// <param name="qos">The parsed QoS, or <see langword="null"/> when the field is absent.</param>
    /// <returns><see langword="false"/> only when <c>qos</c> is present but not a legal MQTT QoS.</returns>
    private static bool TryReadQosStrict(JsonElement o, out int? qos)
    {
        qos = null;
        if (!o.TryGetProperty("qos", out var e)) return true;                        // absent
        if (e.ValueKind != JsonValueKind.Number || !e.TryGetInt32(out var v)) return false;
        if (v is < 0 or > 2) return false;
        qos = v;
        return true;
    }

    /// <summary>
    ///     A human-readable warning for a present-but-invalid <c>qos</c> field, or <see langword="null"/>
    ///     when it is absent or valid — the <c>qos</c> counterpart of
    ///     <see cref="TagConfigJson.DescribeInvalidEnum{TEnum}"/>.
    /// </summary>
    /// <param name="o">The TagConfig root object.</param>
    /// <returns>The warning text, or <see langword="null"/>.</returns>
    private static string? DescribeInvalidQos(JsonElement o)
    {
        if (!o.TryGetProperty("qos", out var e)) return null;
        if (e.ValueKind == JsonValueKind.Number && e.TryGetInt32(out var v) && v is >= 0 and <= 2) return null;
        return $"value '{e.GetRawText()}' for 'qos' is not a valid MQTT QoS; valid: 0, 1, 2";
    }

    private static string ReadString(JsonElement o, string name)
        => o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.String
            ? e.GetString() ?? ""
            : "";

    private static bool ReadBoolOrDefault(JsonElement o, string name, bool defaultValue)
        => o.TryGetProperty(name, out var e) && e.ValueKind is JsonValueKind.True or JsonValueKind.False
            ? e.GetBoolean()
            : defaultValue;
}
