using System.Text.Json;
using System.Text.Json.Nodes;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt;

namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Forms;

/// <summary>
///     Typed working model behind <c>MqttDriverForm</c> — the MQTT driver's whole broker-connection and
///     ingest surface, as authored in the <c>/raw</c> driver-config modal. Kept as a plain class (not
///     nested in the razor) so every rule here is unit-testable; the razor is a thin shell over it.
/// </summary>
/// <remarks>
///     <para>
///     <b>The connection lives on the DRIVER, not the device.</b> MQTT holds exactly one broker session
///     per driver instance, so <c>MqttDeviceForm</c> is informational (the Galaxy precedent) and this
///     form owns host/port/TLS/credentials. Authoring them on the device would work only while the
///     driver has exactly ONE device — <c>DriverDeviceConfigMerger</c> merges a sole device's keys up to
///     the top level and stops doing so the moment a second device exists, at which point the broker
///     connection would silently vanish from the merged blob.
///     </para>
///     <para>
///     <b>One JSON instance, always <see cref="MqttJson.Options"/>.</b> Every MQTT config seam — the
///     runtime factory, the Test-connect probe, the driver's re-parse, the address-picker browser and
///     now this form — parses and serialises through that single shared instance. Divergent per-seam
///     options are this repo's documented systemic driver-enum bug: a form that wrote
///     <c>"mode": 1</c> would be accepted by a seam carrying a <c>JsonStringEnumConverter</c> and fault
///     the one that does not, so "Test connect" goes green and the deployed driver dies. Consequently
///     the emitted keys are <b>PascalCase</b> (the shared instance carries no camelCase naming policy)
///     and enum values are <b>names</b>.
///     </para>
///     <para>
///     <b>Preserving what this form does not author.</b> The inbound blob's keys are retained in a bag
///     and the typed fields are written over it, so (a) the <c>Sparkplug</c> sub-object — P2, Task 21+ —
///     survives a load→save untouched, and (b) any key a newer driver adds is not silently dropped by an
///     older AdminUI. <c>RawTags</c> is the one key deliberately REMOVED: the deploy artifact injects it
///     via <c>DriverDeviceConfigMerger</c>, and a form-authored copy would be dead weight.
///     </para>
/// </remarks>
public sealed class MqttDriverFormModel
{
    /// <summary>
    ///     The <see cref="MqttDriverOptions"/> property name the deploy artifact owns; never authored
    ///     here and stripped from the emitted blob.
    /// </summary>
    private const string RawTagsKey = nameof(MqttDriverOptions.RawTags);

    /// <summary>
    ///     The <see cref="MqttDriverOptions"/> property name for the Sparkplug sub-object. Authored here
    ///     in <see cref="MqttMode.SparkplugB"/> mode and otherwise preserved verbatim from the inbound
    ///     blob — see <see cref="ToJson"/> for why the mode decides which of the two happens.
    /// </summary>
    private const string SparkplugKey = nameof(MqttDriverOptions.Sparkplug);

    /// <summary>
    ///     Characters that cannot appear in a Sparkplug group id. It is a literal MQTT topic segment
    ///     inside the driver's one subscription filter <c>spBv1.0/{GroupId}/#</c>, so a <c>/</c> would
    ///     silently widen the filter and a <c>+</c>/<c>#</c> is not even legal mid-segment. Mirrors
    ///     <c>MqttTagConfigModel</c>'s identical rule on the tag side, so the driver and the tags bound
    ///     to it cannot disagree about what a group id may be.
    /// </summary>
    private static readonly char[] SparkplugGroupIdIllegalChars = ['/', '+', '#'];

    // --- Broker connection ----------------------------------------------------------------------

    /// <summary>Broker hostname or IP address.</summary>
    public string Host { get; set; } = "localhost";

    /// <summary>Broker TCP port (8883 TLS / 1883 plaintext by convention).</summary>
    public int Port { get; set; } = 8883;

    /// <summary>
    ///     MQTT client identifier sent at CONNECT. <b>Blank is the correct default</b> — see
    ///     <see cref="ClientIdPairWarning"/>.
    /// </summary>
    public string ClientId { get; set; } = "";

    // --- Transport security ---------------------------------------------------------------------

    /// <summary>Connect over TLS. Defaults <c>true</c>; this driver ships no plaintext-by-default posture.</summary>
    public bool UseTls { get; set; } = true;

    /// <summary>Accept any self-signed / untrusted broker certificate. Dev / on-prem escape hatch only.</summary>
    public bool AllowUntrustedServerCertificate { get; set; }

    /// <summary>PEM CA file pinning the broker's TLS chain; blank uses the OS trust store.</summary>
    public string CaCertificatePath { get; set; } = "";

    // --- Authentication -------------------------------------------------------------------------

    /// <summary>Username for broker authentication; blank connects without one.</summary>
    public string Username { get; set; } = "";

    /// <summary>
    ///     Password for broker authentication. Stored in the driver's config blob and carried in the
    ///     deployment artifact — the same plaintext-round-trip posture the OPC UA Client driver form
    ///     uses. Never logged: <see cref="MqttDriverOptions"/> redacts it in its own member printer.
    /// </summary>
    public string Password { get; set; } = "";

    // --- Session --------------------------------------------------------------------------------

    /// <summary>MQTT protocol version negotiated at CONNECT.</summary>
    public MqttProtocolVersion ProtocolVersion { get; set; } = MqttProtocolVersion.V500;

    /// <summary>Request a clean session (v3.1.1) / clean start (v5.0) at CONNECT.</summary>
    public bool CleanSession { get; set; } = true;

    /// <summary>Keep-alive interval, in seconds, sent at CONNECT.</summary>
    public int KeepAliveSeconds { get; set; } = 30;

    /// <summary>Bounded connect deadline, in seconds — the driver never hangs past this.</summary>
    public int ConnectTimeoutSeconds { get; set; } = 15;

    /// <summary>Initial reconnect backoff, in seconds, after a connection drop.</summary>
    public int ReconnectMinBackoffSeconds { get; set; } = 1;

    /// <summary>Cap on the exponential reconnect backoff, in seconds.</summary>
    public int ReconnectMaxBackoffSeconds { get; set; } = 30;

    // --- Ingest ---------------------------------------------------------------------------------

    /// <summary>Selects the ingest shape — Plain topics or Sparkplug B (the latter is P2).</summary>
    public MqttMode Mode { get; set; } = MqttMode.Plain;

    /// <summary>Ceiling on an inbound message body, in bytes; a larger message is refused before decode.</summary>
    public int MaxPayloadBytes { get; set; } = 1024 * 1024;

    /// <summary>Plain mode: optional prefix used when the driver composes a topic (e.g. browse scoping).</summary>
    public string TopicPrefix { get; set; } = "";

    /// <summary>Plain mode: default subscription QoS applied when a tag's own <c>qos</c> is unset.</summary>
    public int DefaultQos { get; set; } = 1;

    // --- Sparkplug B ----------------------------------------------------------------------------

    /// <summary>
    ///     Sparkplug mode: the group id. <b>Required</b> — it is the driver's entire subscription scope
    ///     (<c>spBv1.0/{GroupId}/#</c>), so a blank one subscribes to nothing and the driver ingests
    ///     nothing while still reporting a healthy broker connection.
    /// </summary>
    public string SparkplugGroupId { get; set; } = "";

    /// <summary>
    ///     Sparkplug mode: the Host Application identity. Optional, and <b>receive-only</b> in this
    ///     version — see <see cref="SparkplugActAsPrimaryHost"/>.
    /// </summary>
    public string SparkplugHostId { get; set; } = "";

    /// <summary>
    ///     Sparkplug mode: claim the Primary Host Application role. <b>Not implemented</b> — STATE
    ///     publishing is out of scope, and the driver logs a warning rather than being silently inert
    ///     when this is set. Surfaced (rather than hidden) so an operator who needs it learns that from
    ///     the form instead of from a quiet plant.
    /// </summary>
    public bool SparkplugActAsPrimaryHost { get; set; }

    /// <summary>Sparkplug mode: answer a detected sequence-number gap with a rebirth request (NCMD).</summary>
    public bool SparkplugRequestRebirthOnGap { get; set; } = true;

    /// <summary>Sparkplug mode: how long browse/discovery collects births before calling the set stable.</summary>
    public int SparkplugBirthObservationWindowSeconds { get; set; } = 15;

    /// <summary>
    ///     The inbound blob's keys, retained so any key a newer driver adds survives a load→save.
    /// </summary>
    private JsonObject _bag = new();

    /// <summary>
    ///     The operator-facing consequence of pinning <see cref="ClientId"/>, surfaced by the form
    ///     whenever it is non-blank. Observed live during the P1 gate: MQTT requires client ids to be
    ///     unique per broker, and both nodes of a redundant pair run the driver — so a fixed id makes
    ///     them evict each other in a few-second reconnect loop <i>while both still report Healthy</i>.
    /// </summary>
    public const string ClientIdPairWarning =
        "Both nodes of a redundant pair run this driver. MQTT client ids must be unique per broker, so a "
        + "fixed client id makes the two nodes evict each other forever — they reconnect every few seconds "
        + "while both still report Healthy. Leave this blank unless exactly one node will ever connect.";

    /// <summary>
    ///     Loads a model from a <c>DriverConfig</c> JSON string. Typed values are bound through
    ///     <see cref="MqttJson.Options"/> (the same instance the runtime factory uses, so what this form
    ///     shows is what the driver would see); the raw key bag is retained alongside for preservation.
    ///     Never throws — a blank/malformed blob degrades to the driver's own defaults.
    /// </summary>
    /// <param name="json">The raw <c>DriverConfig</c> JSON string, or <c>null</c> for a new driver.</param>
    /// <returns>The populated <see cref="MqttDriverFormModel"/>.</returns>
    public static MqttDriverFormModel FromJson(string? json)
    {
        var bag = TagConfigJson.ParseOrNew(json);
        var o = TryDeserialize(json) ?? new MqttDriverOptions();

        return new MqttDriverFormModel
        {
            Host = o.Host,
            Port = o.Port,
            ClientId = o.ClientId ?? "",
            UseTls = o.UseTls,
            AllowUntrustedServerCertificate = o.AllowUntrustedServerCertificate,
            CaCertificatePath = o.CaCertificatePath ?? "",
            Username = o.Username ?? "",
            Password = o.Password ?? "",
            ProtocolVersion = o.ProtocolVersion,
            CleanSession = o.CleanSession,
            KeepAliveSeconds = o.KeepAliveSeconds,
            ConnectTimeoutSeconds = o.ConnectTimeoutSeconds,
            ReconnectMinBackoffSeconds = o.ReconnectMinBackoffSeconds,
            ReconnectMaxBackoffSeconds = o.ReconnectMaxBackoffSeconds,
            Mode = o.Mode,
            MaxPayloadBytes = o.MaxPayloadBytes,
            TopicPrefix = o.Plain?.TopicPrefix ?? "",
            DefaultQos = o.Plain?.DefaultQos ?? new MqttPlainOptions().DefaultQos,
            SparkplugGroupId = o.Sparkplug?.GroupId ?? "",
            SparkplugHostId = o.Sparkplug?.HostId ?? "",
            SparkplugActAsPrimaryHost = o.Sparkplug?.ActAsPrimaryHost ?? false,
            // Defaulted from the record, not from a literal, so the form shows the driver's own default
            // for a blob that has no Sparkplug sub-object at all.
            SparkplugRequestRebirthOnGap =
                o.Sparkplug?.RequestRebirthOnGap ?? new MqttSparkplugOptions().RequestRebirthOnGap,
            SparkplugBirthObservationWindowSeconds =
                o.Sparkplug?.BirthObservationWindowSeconds
                ?? new MqttSparkplugOptions().BirthObservationWindowSeconds,
            _bag = bag,
        };
    }

    /// <summary>
    ///     Builds the typed options this form authors. Every numeric knob is <b>clamped</b> into the
    ///     range <see cref="MqttDriverOptions"/> declares, so an operator who ignores
    ///     <see cref="Validate"/> and saves anyway still cannot persist a driver-bricking blob — a
    ///     <c>connectTimeoutSeconds: 0</c> is exactly the operator-authorable brick this repo has hit
    ///     before. <see cref="MqttDriverOptions.RawTags"/> stays empty (the deploy artifact owns it) and
    ///     <see cref="MqttDriverOptions.Sparkplug"/> is emitted only in
    ///     <see cref="MqttMode.SparkplugB"/> mode; both are finished by <see cref="ToJson"/>.
    /// </summary>
    /// <returns>The clamped, driver-legal options record.</returns>
    public MqttDriverOptions ToOptions() => new()
    {
        Host = Host.Trim(),
        Port = Math.Clamp(Port, 1, 65535),
        ClientId = Blank(ClientId),
        UseTls = UseTls,
        AllowUntrustedServerCertificate = AllowUntrustedServerCertificate,
        CaCertificatePath = Blank(CaCertificatePath),
        Username = Blank(Username),
        Password = Password,
        ProtocolVersion = ProtocolVersion,
        CleanSession = CleanSession,
        KeepAliveSeconds = Math.Max(1, KeepAliveSeconds),
        ConnectTimeoutSeconds = Math.Max(1, ConnectTimeoutSeconds),
        ReconnectMinBackoffSeconds = Math.Max(1, ReconnectMinBackoffSeconds),
        ReconnectMaxBackoffSeconds = Math.Max(1, ReconnectMaxBackoffSeconds),
        Mode = Mode,
        MaxPayloadBytes = Math.Max(1, MaxPayloadBytes),
        Plain = new MqttPlainOptions
        {
            TopicPrefix = TopicPrefix.Trim(),
            DefaultQos = Math.Clamp(DefaultQos, 0, 2),
        },
        // Null in Plain mode so ToJson leaves whatever the inbound blob had; see its remarks.
        Sparkplug = Mode == MqttMode.SparkplugB
            ? new MqttSparkplugOptions
            {
                GroupId = SparkplugGroupId.Trim(),
                HostId = SparkplugHostId.Trim(),
                ActAsPrimaryHost = SparkplugActAsPrimaryHost,
                RequestRebirthOnGap = SparkplugRequestRebirthOnGap,
                BirthObservationWindowSeconds = Math.Max(1, SparkplugBirthObservationWindowSeconds),
            }
            : null,
    };

    /// <summary>
    ///     Serialises the authored fields over the preserved key bag and returns the <c>DriverConfig</c>
    ///     JSON. Keys are PascalCase and enums are names, because the serialisation runs through
    ///     <see cref="MqttJson.Options"/> — see the type remarks. <c>RawTags</c> is removed (the deploy
    ///     artifact owns it).
    /// </summary>
    /// <remarks>
    ///     <b><c>Sparkplug</c> is merged, never replaced, and only in Sparkplug mode.</b> In
    ///     <see cref="MqttMode.Plain"/> the sub-object is left exactly as the inbound blob had it, so an
    ///     operator who flips a Sparkplug driver to Plain to look at it and flips back does not lose the
    ///     group id. In <see cref="MqttMode.SparkplugB"/> the five fields this form owns are written
    ///     <i>over</i> the existing sub-object rather than replacing it, so a key a newer driver adds
    ///     inside <c>Sparkplug</c> survives an older AdminUI — the same preserve-what-you-do-not-author
    ///     discipline the top-level bag applies.
    /// </remarks>
    /// <returns>The serialised <c>DriverConfig</c> JSON string.</returns>
    public string ToJson()
    {
        var typed = JsonSerializer.SerializeToNode(ToOptions(), MqttJson.Options)!.AsObject();

        // Never let this form's always-empty placeholder overwrite what it does not own.
        typed.Remove(RawTagsKey);

        // Pulled out of the flat copy loop below: unlike every other key, this one merges.
        var authoredSparkplug = TakeIgnoringCase(typed, SparkplugKey) as JsonObject;

        foreach (var (key, value) in typed.ToList())
        {
            // Case-insensitive replace: MqttJson.Options binds a hand-edited camelCase blob happily, so
            // the bag may hold "host" while we emit "Host" — writing both would leave two keys fighting.
            RemoveIgnoringCase(_bag, key);
            _bag[key] = value?.DeepClone();
        }

        if (authoredSparkplug is not null)
        {
            // Merge over whatever was already there, under the key name the bag already uses so a
            // camelCase hand-edited blob does not end up with both "sparkplug" and "Sparkplug".
            var existingKey = FindIgnoringCase(_bag, SparkplugKey) ?? SparkplugKey;
            if (_bag[existingKey] is not JsonObject target)
            {
                target = new JsonObject();
                _bag[existingKey] = target;
            }

            foreach (var (key, value) in authoredSparkplug.ToList())
            {
                RemoveIgnoringCase(target, key);
                target[key] = value?.DeepClone();
            }
        }

        RemoveIgnoringCase(_bag, RawTagsKey);
        return TagConfigJson.Serialize(_bag);
    }

    /// <summary>
    ///     Client-side validation surfaced inline by the form. Returns the first error, or <c>null</c>
    ///     when the model is valid. Every bound mirrors a <c>[Range]</c> on
    ///     <see cref="MqttDriverOptions"/> (or, for QoS, on <see cref="MqttPlainOptions"/>), so the
    ///     authoring surface accepts exactly what the driver accepts.
    /// </summary>
    /// <returns>An error message describing the validation failure, or <c>null</c> when valid.</returns>
    public string? Validate()
    {
        if (string.IsNullOrWhiteSpace(Host)) { return "A broker host is required."; }
        if (Port is < 1 or > 65535) { return "Port must be between 1 and 65535."; }
        if (KeepAliveSeconds < 1) { return "Keep-alive must be at least 1 second."; }
        if (ConnectTimeoutSeconds < 1)
        {
            return "Connect timeout must be at least 1 second — 0 would make every connect attempt "
                 + "expire instantly and the driver would never come up.";
        }
        if (ReconnectMinBackoffSeconds < 1) { return "Minimum reconnect backoff must be at least 1 second."; }
        if (ReconnectMaxBackoffSeconds < 1) { return "Maximum reconnect backoff must be at least 1 second."; }
        if (ReconnectMaxBackoffSeconds < ReconnectMinBackoffSeconds)
        {
            return "Maximum reconnect backoff must not be below the minimum.";
        }
        if (MaxPayloadBytes < 1) { return "Max payload bytes must be at least 1."; }
        if (DefaultQos is < 0 or > 2) { return "Default QoS must be 0, 1 or 2."; }

        if (Mode == MqttMode.SparkplugB)
        {
            var groupId = SparkplugGroupId.Trim();
            if (groupId.Length == 0)
            {
                return "A Sparkplug group ID is required — it is the driver's whole subscription scope "
                     + "(spBv1.0/{GroupId}/#), so a blank one ingests nothing.";
            }

            if (groupId.IndexOfAny(SparkplugGroupIdIllegalChars) >= 0)
            {
                return $"Sparkplug group ID '{groupId}' contains a character ('/', '+', or '#') that "
                     + "cannot appear in an MQTT topic segment.";
            }

            if (SparkplugBirthObservationWindowSeconds < 1)
            {
                return "Birth observation window must be at least 1 second.";
            }
        }

        return null;
    }

    /// <summary>Blank ⇒ <c>null</c> so the key is omitted and the driver's own default applies.</summary>
    /// <param name="value">The raw form value.</param>
    /// <returns>The trimmed value, or <c>null</c> when blank.</returns>
    private static string? Blank(string? value)
        => string.IsNullOrWhiteSpace(value) ? null : value.Trim();

    /// <summary>Removes every key matching <paramref name="name"/> case-insensitively.</summary>
    /// <param name="o">The object to mutate.</param>
    /// <param name="name">The key name to remove.</param>
    private static void RemoveIgnoringCase(JsonObject o, string name)
    {
        foreach (var key in o.Select(p => p.Key)
                     .Where(k => string.Equals(k, name, StringComparison.OrdinalIgnoreCase))
                     .ToList())
        {
            o.Remove(key);
        }
    }

    /// <summary>The actual key in <paramref name="o"/> matching <paramref name="name"/> case-insensitively.</summary>
    /// <param name="o">The object to search.</param>
    /// <param name="name">The key name to look for.</param>
    /// <returns>The matching key as it is spelled in <paramref name="o"/>, or <c>null</c>.</returns>
    private static string? FindIgnoringCase(JsonObject o, string name)
        => o.Select(p => p.Key)
            .FirstOrDefault(k => string.Equals(k, name, StringComparison.OrdinalIgnoreCase));

    /// <summary>Removes and returns the value of a case-insensitively matched key.</summary>
    /// <param name="o">The object to mutate.</param>
    /// <param name="name">The key name to take.</param>
    /// <returns>The detached value, or <c>null</c> when the key was absent (or its value was null).</returns>
    private static JsonNode? TakeIgnoringCase(JsonObject o, string name)
    {
        if (FindIgnoringCase(o, name) is not { } key) { return null; }

        var value = o[key];
        o.Remove(key);

        // Detached before return: a node still parented to `typed` cannot be re-parented into the bag.
        return value?.DeepClone();
    }

    /// <summary>Binds the blob through the shared options; <c>null</c> on blank/malformed input.</summary>
    /// <param name="json">The raw <c>DriverConfig</c> JSON.</param>
    /// <returns>The bound options, or <c>null</c>.</returns>
    private static MqttDriverOptions? TryDeserialize(string? json)
    {
        if (string.IsNullOrWhiteSpace(json)) { return null; }
        try { return JsonSerializer.Deserialize<MqttDriverOptions>(json, MqttJson.Options); }
        catch (JsonException) { return null; }
    }
}
