using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt;

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

/// <summary>
///     Covers the two entry points of <see cref="MqttTagDefinitionFactory"/> and their deliberately
///     different strictness contracts: <c>FromTagConfig</c> is the runtime path (never throws, a
///     malformed blob is a hard reject that upstream turns into <c>BadNodeIdUnknown</c>) and
///     <c>Inspect</c> is the deploy-time path (human-readable warnings so a bad tag surfaces at
///     deploy instead of going dark at runtime).
/// </summary>
public sealed class MqttTagDefinitionFactoryTests
{
    private const string RawPath = "Plant/Mqtt/oven/Temp";

    [Fact]
    public void FromTagConfig_PlainJsonBlob_PopulatesTopicAndPath()
    {
        // NOTE: the plan's sample blob wrote "dataType":"Double"; the authoritative type is
        // DriverDataType (design §6.1), whose 64-bit float member is Float64. "Double" is therefore a
        // typo the strict read rejects — pinned by FromTagConfig_TypoedDataType_RejectsStrict.
        const string blob = """{"topic":"factory/oven/temp","payloadFormat":"Json","jsonPath":"$.value","dataType":"Float64","qos":1}""";
        MqttTagDefinitionFactory.FromTagConfig(blob, RawPath, out var def).ShouldBeTrue();
        def.Topic.ShouldBe("factory/oven/temp");
        def.PayloadFormat.ShouldBe(MqttPayloadFormat.Json);
        def.JsonPath.ShouldBe("$.value");
        def.DataType.ShouldBe(DriverDataType.Float64);
        def.Qos.ShouldBe(1);
    }

    /// <summary>
    ///     The v3 identity contract, stated explicitly so a regression back to blob-keying goes red.
    ///     <see cref="MqttTagDefinition.Name"/> MUST be the RawPath the driver was handed — it is the key
    ///     <c>EquipmentTagRefResolver</c> looks up, the key <c>OnDataChange</c> publishes under, and the
    ///     key <c>DriverHostActor</c> fans out to the raw + UNS NodeIds. Keying it by the TagConfig blob
    ///     (the pre-v3, now-retired shape) would leave the driver silently dead in production while every
    ///     other test here still passed.
    /// </summary>
    [Fact]
    public void FromTagConfig_DefinitionIdentity_IsTheRawPath_NotTheBlob()
    {
        const string blob = """{"topic":"factory/oven/temp","payloadFormat":"Raw"}""";
        MqttTagDefinitionFactory.FromTagConfig(blob, RawPath, out var def).ShouldBeTrue();
        def.Name.ShouldBe(RawPath);
        def.Name.ShouldNotBe(blob);
    }

    /// <summary>
    ///     The identity is whatever RawPath the caller supplies — the factory must never re-derive it
    ///     from the blob's contents (e.g. from <c>topic</c>), or two tags sharing a topic would collide.
    /// </summary>
    [Theory]
    [InlineData("Plant/Mqtt/oven/Temp")]
    [InlineData("SiteA/Mqtt/line3/Oven/Setpoint")]
    public void FromTagConfig_UsesSuppliedRawPathVerbatim(string rawPath)
    {
        MqttTagDefinitionFactory.FromTagConfig(
            """{"topic":"shared/topic","payloadFormat":"Raw"}""", rawPath, out var def).ShouldBeTrue();
        def.Name.ShouldBe(rawPath);
    }

    [Fact]
    public void FromTagConfig_TypoedPayloadFormat_RejectsStrict()
        => MqttTagDefinitionFactory.FromTagConfig(
            """{"topic":"a/b","payloadFormat":"Jason","dataType":"Float64"}""", RawPath, out _).ShouldBeFalse();

    [Fact]
    public void FromTagConfig_TypoedDataType_RejectsStrict()
        => MqttTagDefinitionFactory.FromTagConfig(
            """{"topic":"a/b","payloadFormat":"Json","dataType":"Double"}""", RawPath, out _).ShouldBeFalse();

    [Fact]
    public void Inspect_WildcardTopic_ReturnsWarning()
    {
        // The blob is otherwise clean (payloadFormat parses, dataType + qos absent), so the wildcard
        // check is the ONLY thing that can produce a warning — this cannot pass vacuously.
        var warnings = MqttTagDefinitionFactory.Inspect("""{"topic":"a/+/c","payloadFormat":"Raw"}""");
        warnings.ShouldNotBeEmpty();
        warnings.ShouldHaveSingleItem().ShouldContain("wildcard", Case.Insensitive);
    }

    [Theory]
    [InlineData("a/#")]
    [InlineData("+/b/c")]
    [InlineData("a/+/c")]
    public void Inspect_EachWildcardForm_Warns(string topic)
        => MqttTagDefinitionFactory.Inspect($$"""{"topic":"{{topic}}","payloadFormat":"Raw"}""").ShouldNotBeEmpty();

    [Fact]
    public void FromTagConfig_WildcardTopic_StillParses_WarningIsDeployTimeOnly()
    {
        // A wildcard tag topic is ambiguous, not unparseable: the deploy-time Inspect pass is the
        // designed surface for it. Rejecting it at runtime would turn an authoring mistake into a
        // silent BadNodeIdUnknown with no operator-visible cause.
        MqttTagDefinitionFactory.FromTagConfig(
            """{"topic":"a/+/c","payloadFormat":"Raw"}""", RawPath, out var def).ShouldBeTrue();
        def.Topic.ShouldBe("a/+/c");
    }

    [Fact]
    public void FromTagConfig_RawFormat_AppliesDefaults()
    {
        MqttTagDefinitionFactory.FromTagConfig(
            """{"topic":"factory/oven/blob","payloadFormat":"Raw"}""", RawPath, out var def).ShouldBeTrue();
        def.PayloadFormat.ShouldBe(MqttPayloadFormat.Raw);
        def.JsonPath.ShouldBe("$");                      // absent ⇒ the root default
        def.Qos.ShouldBeNull();                          // absent ⇒ the driver-level DefaultQos wins
        def.RetainSeed.ShouldBeTrue();                   // absent ⇒ seed from the retained message
        def.DataType.ShouldBe(DriverDataType.String);    // absent ⇒ the documented fallback
    }

    [Fact]
    public void FromTagConfig_ExplicitRetainSeedFalse_IsHonoured()
    {
        MqttTagDefinitionFactory.FromTagConfig(
            """{"topic":"a/b","payloadFormat":"Raw","retainSeed":false}""", RawPath, out var def).ShouldBeTrue();
        def.RetainSeed.ShouldBeFalse();
    }

    [Theory]
    [InlineData(0)]
    [InlineData(1)]
    [InlineData(2)]
    public void FromTagConfig_EachLegalQos_IsAccepted(int qos)
    {
        MqttTagDefinitionFactory.FromTagConfig(
            $$"""{"topic":"a/b","payloadFormat":"Raw","qos":{{qos}}}""", RawPath, out var def).ShouldBeTrue();
        def.Qos.ShouldBe(qos);
    }

    [Fact]
    public void FromTagConfig_PlainBlob_LeavesSparkplugDescriptorFieldsNull()
    {
        // P2 (Tasks 15–26) fills these; a plain-mode blob must leave them unset so a future
        // mode discriminator cannot mistake a plain tag for a Sparkplug one.
        MqttTagDefinitionFactory.FromTagConfig(
            """{"topic":"a/b","payloadFormat":"Raw"}""", RawPath, out var def).ShouldBeTrue();
        def.GroupId.ShouldBeNull();
        def.EdgeNodeId.ShouldBeNull();
        def.DeviceId.ShouldBeNull();
        def.MetricName.ShouldBeNull();
    }

    [Theory]
    [InlineData(null)]
    [InlineData("")]
    [InlineData("   ")]
    [InlineData("factory/oven/temp")]        // a bare reference, not a TagConfig blob (no leading '{')
    [InlineData("[1,2,3]")]                  // valid JSON, wrong root kind
    [InlineData("{ not json at all")]        // unparseable — must not throw
    [InlineData("""{"payloadFormat":"Raw"}""")]               // no topic ⇒ nothing to subscribe to
    [InlineData("""{"topic":"","payloadFormat":"Raw"}""")]    // blank topic
    [InlineData("""{"topic":"   ","payloadFormat":"Raw"}""")] // whitespace-only topic
    public void FromTagConfig_MalformedBlob_ReturnsFalseAndNeverThrows(string? tagConfig)
        => MqttTagDefinitionFactory.FromTagConfig(tagConfig!, RawPath, out _).ShouldBeFalse();

    /// <summary>
    ///     A present-but-invalid <c>qos</c> is rejected in every malformed shape, not just the
    ///     out-of-range numeric one. Silently absorbing <c>"high"</c> / <c>1.5</c> into the driver-level
    ///     default would hand the operator a WEAKER delivery guarantee than the one they authored.
    /// </summary>
    [Theory]
    [InlineData("5")]        // out of range
    [InlineData("-1")]       // out of range
    [InlineData("\"high\"")] // wrong JSON type
    [InlineData("\"1\"")]    // stringly-typed number
    [InlineData("1.5")]      // non-integer
    [InlineData("true")]     // wrong JSON type
    [InlineData("null")]     // an explicit null is not "absent"
    public void FromTagConfig_MalformedQos_RejectsStrict(string qosToken)
        => MqttTagDefinitionFactory.FromTagConfig(
            $$"""{"topic":"a/b","payloadFormat":"Raw","qos":{{qosToken}}}""", RawPath, out _).ShouldBeFalse();

    [Fact]
    public void Inspect_CleanBlob_ReturnsEmpty()
        => MqttTagDefinitionFactory.Inspect(
            """{"topic":"a/b","payloadFormat":"Json","jsonPath":"$.v","dataType":"Float64","qos":2}""").ShouldBeEmpty();

    [Fact]
    public void Inspect_TypoedEnums_WarnsPerField()
    {
        var warnings = MqttTagDefinitionFactory.Inspect(
            """{"topic":"a/b","payloadFormat":"Jason","dataType":"Double"}""");
        warnings.Count.ShouldBe(2);
        warnings.ShouldContain(w => w.Contains("payloadFormat", StringComparison.Ordinal));
        warnings.ShouldContain(w => w.Contains("dataType", StringComparison.Ordinal));
    }

    [Theory]
    [InlineData("5")]
    [InlineData("\"high\"")]
    [InlineData("1.5")]
    public void Inspect_MalformedQos_Warns(string qosToken)
    {
        var warnings = MqttTagDefinitionFactory.Inspect(
            $$"""{"topic":"a/b","payloadFormat":"Raw","qos":{{qosToken}}}""");
        warnings.ShouldHaveSingleItem().ShouldContain("qos", Case.Sensitive);
    }

    /// <summary>Every warning source fires together — the pass reports all of them, not just the first.</summary>
    [Fact]
    public void Inspect_MultipleProblems_ReportsAll()
    {
        var warnings = MqttTagDefinitionFactory.Inspect(
            """{"topic":"a/+/c","payloadFormat":"Jason","dataType":"Double","qos":9}""");
        warnings.Count.ShouldBe(4);
    }

    [Fact]
    public void Inspect_UnparseableBlob_Warns()
        => MqttTagDefinitionFactory.Inspect("{ not json at all").ShouldNotBeEmpty();

    [Theory]
    [InlineData(null)]
    [InlineData("")]
    [InlineData("   ")]
    [InlineData("factory/oven/temp")]
    [InlineData("[1,2,3]")]     // no leading '{' ⇒ not a TagConfig blob at all (mirrors Modbus)
    public void Inspect_NotATagConfigBlob_ReturnsEmpty(string? reference)
        => MqttTagDefinitionFactory.Inspect(reference!).ShouldBeEmpty();

    [Fact]
    public void TagConfigKeys_AreTheWireNames_AndAreNotFreeToRename()
    {
        // MqttTagConfigKeys single-sources the address key names across the producer (the AdminUI
        // browse-commit mapper) and the consumer (this factory) — which means a SYMMETRIC rename would
        // keep every round-trip test green while silently unbinding every ALREADY-PERSISTED TagConfig
        // blob in the config DB. These are wire names; pin them to the literals.
        MqttTagConfigKeys.Topic.ShouldBe("topic");
        MqttTagConfigKeys.GroupId.ShouldBe("groupId");
        MqttTagConfigKeys.EdgeNodeId.ShouldBe("edgeNodeId");
        MqttTagConfigKeys.DeviceId.ShouldBe("deviceId");
        MqttTagConfigKeys.MetricName.ShouldBe("metricName");
    }

    [Fact]
    public void TagConfigKeys_AreTheKeysTheFactoryActuallyReads()
    {
        // The pin above is only worth having if the factory really reads THESE keys — a blob written
        // entirely from the constants must parse.
        var blob = $$"""
            {"{{MqttTagConfigKeys.GroupId}}":"G",
             "{{MqttTagConfigKeys.EdgeNodeId}}":"E",
             "{{MqttTagConfigKeys.DeviceId}}":"D",
             "{{MqttTagConfigKeys.MetricName}}":"M"}
            """;

        MqttTagDefinitionFactory.FromSparkplugTagConfig(blob, RawPath, out var def).ShouldBeTrue();
        def.GroupId.ShouldBe("G");
        def.EdgeNodeId.ShouldBe("E");
        def.DeviceId.ShouldBe("D");
        def.MetricName.ShouldBe("M");

        MqttTagDefinitionFactory
            .FromTagConfig($$"""{"{{MqttTagConfigKeys.Topic}}":"a/b"}""", RawPath, out var plain)
            .ShouldBeTrue();
        plain.Topic.ShouldBe("a/b");
    }
}
