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

namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;

/// <summary>
///     Typed working model for an MQTT tag's TagConfig JSON — the driver-specific binding fields
///     (name / access level / writability live on the Tag entity). Preserves unrecognised JSON keys
///     across a load→save.
/// </summary>
/// <remarks>
///     <para>
///     The authoritative consumer of the produced blob is
///     <c>MqttTagDefinitionFactory.FromTagConfig</c> (<c>Driver.Mqtt.Contracts</c>); every key name and
///     strictness rule here mirrors that factory rather than inventing an editor-side schema.
///     </para>
///     <para>
///     <b>There is deliberately no <c>FullName</c> (or any other identity) key.</b> Under the v3
///     identity contract a tag is identified by its <b>RawPath</b> — the factory keys the produced
///     definition's <c>Name</c> off the RawPath it is handed, and the TagConfig is a pure address blob.
///     Writing a composed identity key here would be dead weight nothing reads.
///     </para>
///     <para>
///     <see cref="Mode"/> is a <b>UI-only</b> sub-shape selector, inferred from the blob (the presence
///     of any Sparkplug descriptor key) and never serialised — the driver takes its
///     <c>Plain</c>/<c>SparkplugB</c> mode from the <em>driver</em> config, not from a tag, so
///     persisting a per-tag <c>mode</c> key would add a field the contract does not define.
///     Consequently a SparkplugB tag survives a save→reopen purely because the descriptor keys it
///     writes (<see cref="GroupId"/>/<see cref="EdgeNodeId"/>/<see cref="DeviceId"/>/
///     <see cref="MetricName"/>) re-infer the mode on the next load — there is nothing else to persist.
///     </para>
/// </remarks>
public sealed class MqttTagConfigModel
{
    private const string TopicKey = "topic";
    private const string PayloadFormatKey = "payloadFormat";
    private const string JsonPathKey = "jsonPath";
    private const string DataTypeKey = "dataType";
    private const string QosKey = "qos";
    private const string RetainSeedKey = "retainSeed";
    private const string GroupIdKey = "groupId";
    private const string EdgeNodeIdKey = "edgeNodeId";
    private const string DeviceIdKey = "deviceId";
    private const string MetricNameKey = "metricName";

    /// <summary>
    ///     The JSONPath the driver applies when the blob omits <c>jsonPath</c> — the document root.
    ///     Seeded into an unauthored tag's field so the "no extraction needed" case is one click away
    ///     rather than something the operator has to know.
    /// </summary>
    private const string RootJsonPath = "$";

    /// <summary>The Sparkplug B descriptor keys — any non-blank one marks a tag SparkplugB (see <see cref="InferMode"/>).</summary>
    private static readonly string[] SparkplugKeys = [GroupIdKey, EdgeNodeIdKey, DeviceIdKey, MetricNameKey];

    /// <summary>The MQTT wildcard characters; a tag's subscription topic must be concrete.</summary>
    private static readonly char[] TopicWildcards = ['+', '#'];

    /// <summary>
    ///     Characters illegal in a Sparkplug group/edge-node/device id. <c>/</c> is rejected because
    ///     these ids are literal MQTT topic segments — a decoded incoming id can never contain one
    ///     (the broker has already split the topic into segments before the driver sees it), so an
    ///     authored id containing <c>/</c> could never match a real birth: a permanently dead binding,
    ///     not an ambiguous one. <c>+</c>/<c>#</c> are rejected for the same "no legitimate
    ///     interpretation" reason the Plain topic wildcard check uses — here doubly so, since
    ///     <see cref="MqttDriverOptions.GroupId"/>'s only consumer builds the literal subscription
    ///     filter <c>spBv1.0/{GroupId}/#</c>, and embedding a wildcard character mid-segment is not
    ///     even legal there. <b>Not enforced by <c>MqttTagDefinitionFactory.FromSparkplugTagConfig</c></b>
    ///     — this is an editor-side rule stricter than the runtime parser, the same shape as the Plain
    ///     topic-wildcard rule below.
    /// </summary>
    private static readonly char[] SparkplugSegmentIllegalChars = ['/', '+', '#'];

    /// <summary>
    ///     Which ingest shape this tag is authored under — inferred from the blob, never serialised.
    /// </summary>
    public MqttMode Mode { get; set; } = MqttMode.Plain;

    /// <summary>The concrete MQTT topic the tag subscribes to (Plain mode). Required; no wildcards.</summary>
    public string Topic { get; set; } = "";

    /// <summary>How the received payload is decoded (Plain mode).</summary>
    public MqttPayloadFormat PayloadFormat { get; set; } = MqttPayloadFormat.Json;

    /// <summary>
    ///     JSONPath selecting the value inside a <see cref="MqttPayloadFormat.Json"/> payload. Blank is
    ///     legal and NOT a validation failure — the key is omitted and the driver applies the document
    ///     root (<c>$</c>), which is the real "publisher puts a bare JSON scalar on the topic" case.
    ///     An <b>unauthored</b> tag is seeded with <c>$</c> by <see cref="FromJson"/> so that common case
    ///     is visible and one click away; see <see cref="RootJsonPath"/>.
    /// </summary>
    public string JsonPath { get; set; } = "";

    /// <summary>The tag's declared value type (Plain mode; always written). The driver's <see cref="DriverDataType"/> set.</summary>
    public DriverDataType DataType { get; set; } = DriverDataType.String;

    /// <summary>
    ///     Per-tag subscription QoS (0–2), or <c>null</c> to omit the key and inherit the driver-level
    ///     default. An absent key stays absent through a load→save. Shared by both modes —
    ///     <c>MqttTagDefinitionFactory</c> reads <c>qos</c> identically for Plain and Sparkplug.
    /// </summary>
    public int? Qos { get; set; }

    /// <summary>
    ///     Whether the broker's retained message seeds the tag's initial value, or <c>null</c> to omit
    ///     the key and inherit the driver's default (<c>true</c>). An absent key stays absent. Shared by
    ///     both modes — <c>MqttTagDefinitionFactory</c> reads <c>retainSeed</c> identically for Plain and
    ///     Sparkplug.
    /// </summary>
    public bool? RetainSeed { get; set; }

    /// <summary>The Sparkplug group id (Sparkplug mode). Required; becomes part of the subscription filter.</summary>
    public string GroupId { get; set; } = "";

    /// <summary>The Sparkplug edge-node id (Sparkplug mode). Required.</summary>
    public string EdgeNodeId { get; set; } = "";

    /// <summary>
    ///     The Sparkplug device id (Sparkplug mode), or blank for a metric published by the edge node
    ///     itself rather than a device beneath it. Optional.
    /// </summary>
    public string DeviceId { get; set; } = "";

    /// <summary>
    ///     The Sparkplug metric's stable name (Sparkplug mode). Required. Unlike
    ///     <see cref="GroupId"/>/<see cref="EdgeNodeId"/>/<see cref="DeviceId"/> this is NOT a topic
    ///     segment — it is read from the birth/data payload — so it is deliberately unrestricted and MAY
    ///     contain <c>/</c> (the canonical Sparkplug examples do: <c>Node Control/Rebirth</c>,
    ///     <c>Properties/Hardware Make</c>).
    /// </summary>
    public string MetricName { get; set; } = "";

    /// <summary>
    ///     Sparkplug-mode data-type OVERRIDE (Sparkplug mode only), or <c>null</c> to omit the key and
    ///     let the tag take whatever type the metric's birth certificate declares — the whole point of
    ///     the driver's <c>UntilStable</c> discovery. Distinct from <see cref="DataType"/>, which is the
    ///     always-written Plain-mode field; both read/write the same <c>dataType</c> JSON key
    ///     (<c>MqttTagDefinitionFactory.FromSparkplugTagConfig</c> reads it as a <see cref="DriverDataType"/>
    ///     override, strictly, when present — the same enum Plain mode uses, not the raw wire
    ///     <c>SparkplugDataType</c>).
    /// </summary>
    public DriverDataType? MetricDataType { get; set; }

    private JsonObject _bag = new();

    /// <summary>
    ///     Loads a model from a TagConfig JSON string, defaulting any absent field and retaining every
    ///     original key (so fields this editor doesn't expose — history intent, array/alarm objects, and
    ///     the Sparkplug descriptor keys — survive a load→save).
    /// </summary>
    /// <remarks>
    ///     An <b>unauthored</b> Plain tag — no <c>topic</c> AND no <c>jsonPath</c>, i.e. a brand-new tag
    ///     rather than an existing one the operator deliberately left path-less — has its
    ///     <see cref="JsonPath"/> seeded to <see cref="RootJsonPath"/>. The condition is deliberately
    ///     narrow: an existing tag that carries a topic and no <c>jsonPath</c> keeps the key ABSENT
    ///     through a load→save, so this can never rewrite already-deployed blobs.
    /// </remarks>
    /// <param name="json">The raw TagConfig JSON string, or <c>null</c> for a new/empty config.</param>
    /// <returns>The populated <see cref="MqttTagConfigModel"/>.</returns>
    public static MqttTagConfigModel FromJson(string? json)
    {
        var o = TagConfigJson.ParseOrNew(json);
        var topic = TagConfigJson.GetString(o, TopicKey) ?? "";
        var jsonPath = TagConfigJson.GetString(o, JsonPathKey) ?? "";
        var mode = InferMode(o);
        if (mode == MqttMode.Plain && topic.Length == 0 && jsonPath.Length == 0) { jsonPath = RootJsonPath; }

        return new MqttTagConfigModel
        {
            Mode = mode,
            Topic = topic,
            PayloadFormat = TagConfigJson.GetEnum(o, PayloadFormatKey, MqttPayloadFormat.Json),
            JsonPath = jsonPath,
            DataType = TagConfigJson.GetEnum(o, DataTypeKey, DriverDataType.String),
            Qos = GetIntNullable(o, QosKey),
            RetainSeed = TagConfigJson.GetBoolNullable(o, RetainSeedKey),
            GroupId = TagConfigJson.GetString(o, GroupIdKey) ?? "",
            EdgeNodeId = TagConfigJson.GetString(o, EdgeNodeIdKey) ?? "",
            DeviceId = TagConfigJson.GetString(o, DeviceIdKey) ?? "",
            MetricName = TagConfigJson.GetString(o, MetricNameKey) ?? "",
            // Same "dataType" key as Plain's DataType above, read as an optional override — see the
            // MetricDataType doc comment for why there are two typed fields over one JSON key.
            MetricDataType = GetEnumNullable<DriverDataType>(o, DataTypeKey),
            _bag = o,
        };
    }

    /// <summary>
    ///     Serialises this model back to a TagConfig JSON string over the preserved key bag. Enums are
    ///     written as their <b>names</b> (the driver reads them strictly by name; an ordinal would be
    ///     rejected outright), and blank/null optionals are written as an absent key so the driver's own
    ///     defaults apply.
    /// </summary>
    /// <remarks>
    ///     <c>dataType</c> is the one key both modes write, from two different typed fields
    ///     (<see cref="DataType"/> for Plain — always present; <see cref="MetricDataType"/> for
    ///     Sparkplug — omitted unless the operator set an override): which field wins is decided by
    ///     <see cref="Mode"/> at write time. Every other key is unconditional — mode-inapplicable keys
    ///     (e.g. <c>topic</c> while Sparkplug, or <c>groupId</c> while Plain) are written/cleared exactly
    ///     as their typed field says, which naturally preserves a retyped tag's other-mode leftovers
    ///     untouched until the operator edits that field too.
    /// </remarks>
    /// <returns>The serialised TagConfig JSON string.</returns>
    public string ToJson()
    {
        // Blank ⇒ omit, like every other optional here: toggling the shape dropdown on an untouched tag
        // must not leave a stray "topic":"" behind.
        TagConfigJson.Set(_bag, TopicKey, string.IsNullOrWhiteSpace(Topic) ? null : Topic.Trim());
        TagConfigJson.Set(_bag, PayloadFormatKey, PayloadFormat);
        TagConfigJson.Set(_bag, JsonPathKey, string.IsNullOrWhiteSpace(JsonPath) ? null : JsonPath.Trim());
        TagConfigJson.Set(_bag, QosKey, Qos);
        TagConfigJson.Set(_bag, RetainSeedKey, RetainSeed);

        // Mode-dependent: Plain always writes DataType; Sparkplug writes MetricDataType (null ⇒ omitted,
        // "take the birth's declared type").
        TagConfigJson.Set(_bag, DataTypeKey, Mode == MqttMode.SparkplugB ? MetricDataType : DataType);

        TagConfigJson.Set(_bag, GroupIdKey, string.IsNullOrWhiteSpace(GroupId) ? null : GroupId.Trim());
        TagConfigJson.Set(_bag, EdgeNodeIdKey, string.IsNullOrWhiteSpace(EdgeNodeId) ? null : EdgeNodeId.Trim());
        TagConfigJson.Set(_bag, DeviceIdKey, string.IsNullOrWhiteSpace(DeviceId) ? null : DeviceId.Trim());
        TagConfigJson.Set(_bag, MetricNameKey, string.IsNullOrWhiteSpace(MetricName) ? null : MetricName.Trim());
        return TagConfigJson.Serialize(_bag);
    }

    /// <summary>
    ///     Client-side validation run by <see cref="TagConfigValidator"/> before a tag is saved.
    ///     Returns the first error, or <c>null</c> when the config is valid.
    /// </summary>
    /// <remarks>
    ///     <para>
    ///     Exactly ONE rule is deliberately <b>stricter</b> than the runtime parser: a wildcard topic,
    ///     which the runtime accepts and <c>Inspect</c> only warns about at deploy. It earns the
    ///     strictness because a wildcard has no legitimate single-Tag interpretation — one Tag holds one
    ///     value, and <c>+</c>/<c>#</c> would feed it from many source topics. Everything else —
    ///     required topic, strict enums, QoS 0–2 — matches <c>MqttTagDefinitionFactory</c> exactly.
    ///     </para>
    ///     <para>
    ///     A blank <c>jsonPath</c> under a <c>Json</c> payload is explicitly <b>accepted</b>, matching
    ///     the runtime's document-root default. Rejecting it would make this editor refuse a config the
    ///     driver handles happily — inverting the "authoring surface accepts ⇔ publish accepts"
    ///     principle — and, because this validator also gates the CSV-import review grid
    ///     (<c>RawManualTagEntryModal</c>), would block whole import batches over a sane default. The
    ///     operator is guided by the seeded <c>$</c> from <see cref="FromJson"/> instead of a blocker.
    ///     </para>
    ///     <para>
    ///     The strict enum/QoS checks read the ORIGINAL key bag, not the defaulted typed fields, so a
    ///     blob that never went through this editor (e.g. a CSV import) cannot pass validation while the
    ///     driver would reject it.
    ///     </para>
    ///     <para>
    ///     <b>Sparkplug rules mirror <c>MqttTagDefinitionFactory.FromSparkplugTagConfig</c></b>:
    ///     <c>groupId</c>/<c>edgeNodeId</c>/<c>metricName</c> required (blank rejected exactly as the
    ///     factory hard-rejects them), <c>deviceId</c> optional, <c>dataType</c> and <c>qos</c> read
    ///     strictly but ONLY when present (matching <c>TryReadEnumStrict</c>'s absent/valid/invalid
    ///     split — a Sparkplug tag legitimately omits <c>dataType</c> and takes whatever the birth
    ///     certificate declares). Plain-only fields (<c>topic</c>, <c>payloadFormat</c>, <c>jsonPath</c>)
    ///     are NOT checked in this mode, matching the factory's "Plain-shape keys are read but not
    ///     required" stance for a retyped blob's leftovers.
    ///     </para>
    ///     <para>
    ///     ONE Sparkplug rule is likewise stricter than the factory: <c>groupId</c>/<c>edgeNodeId</c>/
    ///     <c>deviceId</c> reject <c>/</c>, <c>+</c>, and <c>#</c> — see
    ///     <see cref="SparkplugSegmentIllegalChars"/> for why (a decoded incoming id can never contain
    ///     one, so an authored id that does is a permanently dead binding, and <c>+</c>/<c>#</c> would
    ///     corrupt the driver's own subscription filter). <c>metricName</c> carries NO such restriction —
    ///     it is not a topic segment, and Sparkplug's own canonical metric names use <c>/</c> (e.g.
    ///     <c>Node Control/Rebirth</c>); rejecting it would block legitimate authoring.
    ///     </para>
    /// </remarks>
    /// <returns>An error message describing the validation failure, or <c>null</c> when valid.</returns>
    public string? Validate()
    {
        if (Mode == MqttMode.SparkplugB) { return ValidateSparkplug(); }

        if (DescribeInvalidEnum<MqttPayloadFormat>(_bag, PayloadFormatKey) is { } pfError) { return pfError; }
        if (DescribeInvalidEnum<DriverDataType>(_bag, DataTypeKey) is { } dtError) { return dtError; }
        if (DescribeInvalidQos(_bag) is { } qosError) { return qosError; }

        var topic = Topic.Trim();
        if (string.IsNullOrEmpty(topic)) { return "A topic is required."; }
        if (topic.IndexOfAny(TopicWildcards) >= 0)
        {
            return $"Topic '{topic}' contains an MQTT wildcard (+ or #); a tag's topic must be concrete, "
                 + "or the tag would be fed by every matching topic.";
        }

        // NB no jsonPath rule — see the remarks above. A blank path is the driver's document-root
        // default, not an authoring error.
        return null;
    }

    /// <summary>Sparkplug-mode half of <see cref="Validate"/> — see its remarks for the full rationale.</summary>
    /// <returns>An error message describing the validation failure, or <c>null</c> when valid.</returns>
    private string? ValidateSparkplug()
    {
        // Same DriverDataType enum + same strict absent/valid/invalid split as Plain's DataType check —
        // dataType is one JSON key read by both FromTagConfig and FromSparkplugTagConfig identically.
        if (DescribeInvalidEnum<DriverDataType>(_bag, DataTypeKey) is { } dtError) { return dtError; }
        if (DescribeInvalidQos(_bag) is { } qosError) { return qosError; }

        var groupId = GroupId.Trim();
        var edgeNodeId = EdgeNodeId.Trim();
        var deviceId = DeviceId.Trim();
        var metricName = MetricName.Trim();

        if (string.IsNullOrEmpty(groupId)) { return "A Sparkplug group ID is required."; }
        if (string.IsNullOrEmpty(edgeNodeId)) { return "A Sparkplug edge node ID is required."; }
        if (string.IsNullOrEmpty(metricName)) { return "A Sparkplug metric name is required."; }

        if (DescribeInvalidSparkplugSegment("group ID", groupId) is { } gErr) { return gErr; }
        if (DescribeInvalidSparkplugSegment("edge node ID", edgeNodeId) is { } eErr) { return eErr; }
        // deviceId is optional — only validated when the operator actually supplied one.
        if (deviceId.Length > 0 && DescribeInvalidSparkplugSegment("device ID", deviceId) is { } dErr) { return dErr; }

        return null;
    }

    /// <summary>
    ///     Describes an <paramref name="value"/> that is illegal as a Sparkplug group/edge-node/device
    ///     id segment (see <see cref="SparkplugSegmentIllegalChars"/>), or <c>null</c> when clean.
    /// </summary>
    /// <param name="fieldLabel">The human-readable field name for the error message.</param>
    /// <param name="value">The trimmed, non-blank field value to check.</param>
    /// <returns>The error text, or <c>null</c>.</returns>
    private static string? DescribeInvalidSparkplugSegment(string fieldLabel, string value)
        => value.IndexOfAny(SparkplugSegmentIllegalChars) >= 0
            ? $"Sparkplug {fieldLabel} '{value}' contains a character ('/', '+', or '#') that cannot appear " +
              "in an MQTT topic segment; a decoded incoming id can never contain one, so this could never bind."
            : null;

    /// <summary>
    ///     Infers the editor's sub-shape from the blob: any non-blank Sparkplug descriptor key marks a
    ///     Sparkplug tag, otherwise Plain.
    /// </summary>
    /// <param name="o">The parsed TagConfig key bag.</param>
    /// <returns>The inferred mode.</returns>
    private static MqttMode InferMode(JsonObject o)
        => SparkplugKeys.Any(k => !string.IsNullOrWhiteSpace(TagConfigJson.GetString(o, k)))
            ? MqttMode.SparkplugB
            : MqttMode.Plain;

    /// <summary>Reads an int value, or <c>null</c> when absent/null/not an integer (so an absent key stays absent).</summary>
    /// <param name="o">The JSON object to read from.</param>
    /// <param name="name">The property name to read.</param>
    /// <returns>The int value, or <c>null</c>.</returns>
    private static int? GetIntNullable(JsonObject o, string name)
        => o.TryGetPropertyValue(name, out var n) && n is JsonValue v && v.TryGetValue<int>(out var i) ? i : null;

    /// <summary>
    ///     Reads an enum by its serialised name, or <c>null</c> when absent/unparseable — the nullable
    ///     counterpart of <see cref="TagConfigJson.GetEnum{TEnum}"/>, used for <see cref="MetricDataType"/>
    ///     so "the key is absent" (take the birth's declared type) is distinguishable from any real member.
    /// </summary>
    /// <typeparam name="TEnum">The enum type to parse.</typeparam>
    /// <param name="o">The JSON object to read from.</param>
    /// <param name="name">The property name to read.</param>
    /// <returns>The parsed enum value, or <c>null</c>.</returns>
    private static TEnum? GetEnumNullable<TEnum>(JsonObject o, string name) where TEnum : struct, Enum
        => TagConfigJson.GetString(o, name) is { } s && Enum.TryParse<TEnum>(s, ignoreCase: true, out var v) ? v : null;

    /// <summary>
    ///     Describes a present-but-invalid enum field, or <c>null</c> when it is absent or valid.
    ///     Mirrors <c>TagConfigJson.TryReadEnum</c>'s absent/valid/invalid split exactly — including its
    ///     treatment of a present-but-non-string value as ABSENT — so the editor never blocks a blob the
    ///     driver would happily default.
    /// </summary>
    /// <typeparam name="TEnum">The enum type expected.</typeparam>
    /// <param name="o">The TagConfig key bag.</param>
    /// <param name="name">The property name to describe.</param>
    /// <returns>The error text, or <c>null</c>.</returns>
    private static string? DescribeInvalidEnum<TEnum>(JsonObject o, string name) where TEnum : struct, Enum
    {
        if (!o.TryGetPropertyValue(name, out var n) || n is not JsonValue v || !v.TryGetValue<string>(out var raw))
        {
            return null;
        }
        return Enum.TryParse<TEnum>(raw, ignoreCase: true, out _)
            ? null
            : $"'{raw}' is not a valid {name}; valid: {string.Join(", ", Enum.GetNames<TEnum>())}.";
    }

    /// <summary>
    ///     Describes a present-but-invalid <c>qos</c> field, or <c>null</c> when it is absent or a legal
    ///     MQTT QoS. Matches the factory's strict read: absent ⇒ fine; a JSON integer 0–2 ⇒ fine;
    ///     anything else present (non-number, non-integer, out of range) ⇒ rejected.
    /// </summary>
    /// <param name="o">The TagConfig key bag.</param>
    /// <returns>The error text, or <c>null</c>.</returns>
    private static string? DescribeInvalidQos(JsonObject o)
    {
        if (!o.TryGetPropertyValue(QosKey, out var n) || n is null) { return null; }
        if (n is JsonValue v && v.TryGetValue<int>(out var i) && i is >= 0 and <= 2) { return null; }
        return $"'{n.ToJsonString()}' is not a valid QoS; valid: 0, 1, 2.";
    }
}
