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

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

/// <summary>
///     Pins <see cref="SqlEquipmentTagParser.TryParse"/> against its documented contract: what it accepts, what
///     it captures verbatim, and — the larger half — everything it must <b>reject</b>. Rejection is the whole
///     safety story here: a blob the parser waves through becomes an OPC UA node the driver cannot feed, or (for
///     a typo'd enum) a node fed from the <em>wrong</em> model while publishing a confident <c>Good</c> (R2-11).
/// </summary>
public class SqlEquipmentTagParserTests
{
    [Fact]
    public void TryParse_KeyValue_readsFields_andKeepsMaliciousValueVerbatim()
    {
        var json = """
        {"driver":"Sql","model":"KeyValue","table":"dbo.TagValues","keyColumn":"tag_name",
         "keyValue":"'; DROP TABLE x --","valueColumn":"num_value","timestampColumn":"sample_ts","type":"Float64"}
        """;
        SqlEquipmentTagParser.TryParse(json, "Plant/Sql/dev1/Speed", out var def).ShouldBeTrue();
        def.Model.ShouldBe(SqlTagModel.KeyValue);
        def.Table.ShouldBe("dbo.TagValues");
        def.KeyValue.ShouldBe("'; DROP TABLE x --");   // captured as-is; the reader BINDS it, never concatenates
        def.DeclaredType.ShouldBe(DriverDataType.Float64);
        def.Name.ShouldBe("Plant/Sql/dev1/Speed");     // Name == RawPath (forward-router key)
    }

    [Fact]
    public void TryParse_invalidModelEnum_rejectsTag()
        => SqlEquipmentTagParser.TryParse(
            """{"driver":"Sql","model":"Nonsense","table":"t","keyColumn":"k","keyValue":"v","valueColumn":"c"}""",
            "raw", out _).ShouldBeFalse();

    // ---- WideRow: the where-pair selector ----

    [Fact]
    public void TryParse_WideRow_wherePair_readsColumnAndSelector_andLeavesTopByTimestampUnset()
    {
        var json = """
        {"driver":"Sql","model":"WideRow","table":"dbo.LatestStatus","columnName":"oven_temp",
         "timestampColumn":"sample_ts","rowSelector":{"whereColumn":"station_id","whereValue":"7"}}
        """;

        SqlEquipmentTagParser.TryParse(json, "Plant/Sql/dev1/OvenTemp", out var def).ShouldBeTrue();
        def.Model.ShouldBe(SqlTagModel.WideRow);
        def.Table.ShouldBe("dbo.LatestStatus");
        def.ColumnName.ShouldBe("oven_temp");
        def.TimestampColumn.ShouldBe("sample_ts");
        def.RowSelectorColumn.ShouldBe("station_id");
        def.RowSelectorValue.ShouldBe("7");
        def.RowSelectorTopByTimestamp.ShouldBeNull();
        def.Name.ShouldBe("Plant/Sql/dev1/OvenTemp");
        // Wide-row carries none of the key-value fields.
        def.KeyColumn.ShouldBeNull();
        def.KeyValue.ShouldBeNull();
        def.ValueColumn.ShouldBeNull();
        def.DeclaredType.ShouldBeNull();   // no "type" authored ⇒ infer from column metadata
    }

    [Theory]
    [InlineData("7", "7")]          // JSON number → its raw token, so the reader binds one string either way
    [InlineData("\"7\"", "7")]      // JSON string → its contents
    [InlineData("true", "true")]    // JSON bool → its raw token (lower-case, as written)
    public void TryParse_WideRow_whereValue_isCapturedFromEitherAStringOrABareScalar(
        string authored, string expected)
    {
        var json = """{"driver":"Sql","model":"WideRow","table":"T","columnName":"c","rowSelector":{"whereColumn":"station_id","whereValue":"""
                   + authored + "}}";

        SqlEquipmentTagParser.TryParse(json, "raw", out var def).ShouldBeTrue();
        def.RowSelectorValue.ShouldBe(expected);
    }

    [Fact]
    public void TryParse_WideRow_keepsAMaliciousWhereValueVerbatim()
    {
        var json = """
        {"driver":"Sql","model":"WideRow","table":"T","columnName":"c",
         "rowSelector":{"whereColumn":"station_id","whereValue":"7'); DROP TABLE x --"}}
        """;

        SqlEquipmentTagParser.TryParse(json, "raw", out var def).ShouldBeTrue();
        // Captured as-is: the planner BINDS it as @w, so sanitising here would only corrupt legal values.
        def.RowSelectorValue.ShouldBe("7'); DROP TABLE x --");
    }

    // ---- WideRow: the topByTimestamp selector ----

    [Fact]
    public void TryParse_WideRow_topByTimestamp_readsTheOrderColumn_andLeavesTheWherePairUnset()
    {
        var json = """
        {"driver":"Sql","model":"WideRow","table":"dbo.Readings","columnName":"oven_temp",
         "rowSelector":{"topByTimestamp":"sample_ts"},"type":"Float32"}
        """;

        SqlEquipmentTagParser.TryParse(json, "raw", out var def).ShouldBeTrue();
        def.RowSelectorTopByTimestamp.ShouldBe("sample_ts");
        def.RowSelectorColumn.ShouldBeNull();
        def.RowSelectorValue.ShouldBeNull();
        def.DeclaredType.ShouldBe(DriverDataType.Float32);
    }

    [Fact]
    public void TryParse_WideRow_bothSelectorsAuthored_keepsOnlyTheWherePair()
    {
        var json = """
        {"driver":"Sql","model":"WideRow","table":"T","columnName":"c",
         "rowSelector":{"whereColumn":"station_id","whereValue":"7","topByTimestamp":"sample_ts"}}
        """;

        SqlEquipmentTagParser.TryParse(json, "raw", out var def).ShouldBeTrue();
        // SqlGroupPlanner relies on exactly one selector being populated — it branches on the where-pair
        // first and would otherwise silently drop the topByTimestamp ordering from a group's key.
        def.RowSelectorColumn.ShouldBe("station_id");
        def.RowSelectorValue.ShouldBe("7");
        def.RowSelectorTopByTimestamp.ShouldBeNull();
    }

    // ---- WideRow rejections ----

    [Theory]
    [InlineData("""{"driver":"Sql","model":"WideRow","table":"T","rowSelector":{"topByTimestamp":"ts"}}""")]
    [InlineData("""{"driver":"Sql","model":"WideRow","table":"T","columnName":"","rowSelector":{"topByTimestamp":"ts"}}""")]
    [InlineData("""{"driver":"Sql","model":"WideRow","table":"T","columnName":"   ","rowSelector":{"topByTimestamp":"ts"}}""")]
    [InlineData("""{"driver":"Sql","model":"WideRow","table":"T","columnName":42,"rowSelector":{"topByTimestamp":"ts"}}""")]
    public void TryParse_WideRow_withoutAUsableColumnName_rejectsTag(string json)
        => SqlEquipmentTagParser.TryParse(json, "raw", out _).ShouldBeFalse();

    [Theory]
    // No rowSelector object at all.
    [InlineData("""{"driver":"Sql","model":"WideRow","table":"T","columnName":"c"}""")]
    // Present but empty.
    [InlineData("""{"driver":"Sql","model":"WideRow","table":"T","columnName":"c","rowSelector":{}}""")]
    // Half a where-pair is not a selector: a whereColumn with no value…
    [InlineData("""{"driver":"Sql","model":"WideRow","table":"T","columnName":"c","rowSelector":{"whereColumn":"station_id"}}""")]
    // …and a whereValue with no column.
    [InlineData("""{"driver":"Sql","model":"WideRow","table":"T","columnName":"c","rowSelector":{"whereValue":"7"}}""")]
    // rowSelector authored as the wrong JSON kind is ignored, leaving no selector.
    [InlineData("""{"driver":"Sql","model":"WideRow","table":"T","columnName":"c","rowSelector":"station_id=7"}""")]
    public void TryParse_WideRow_withNoRowSelector_rejectsTag(string json)
    {
        // Accepting one would author a node whose query cannot say WHICH row it reads.
        SqlEquipmentTagParser.TryParse(json, "raw", out _).ShouldBeFalse();
    }

    // ---- recognition + shared rejections ----

    [Theory]
    [InlineData("""["not","an","object"]""")]
    [InlineData(""""just a string"""")]
    [InlineData("42")]
    [InlineData("null")]
    [InlineData("not json at all")]
    [InlineData("")]
    [InlineData("   ")]
    public void TryParse_aNonObjectRoot_rejectsTag_withoutThrowing(string reference)
        => SqlEquipmentTagParser.TryParse(reference, "raw", out _).ShouldBeFalse();

    [Fact]
    public void TryParse_aBlobWithNeitherADriverMarkerNorAModelDiscriminator_rejectsTag()
    {
        // Otherwise another driver's TagConfig that happens to carry a "table" would be served as a Sql tag.
        const string json =
            """{"table":"dbo.TagValues","keyColumn":"tag_name","keyValue":"v","valueColumn":"num_value"}""";
        SqlEquipmentTagParser.TryParse(json, "raw", out _).ShouldBeFalse();
    }

    [Fact]
    public void TryParse_aBlobBelongingToAnotherDriver_rejectsTag()
        => SqlEquipmentTagParser.TryParse(
            """{"driver":"Modbus","table":"T","keyColumn":"k","keyValue":"v","valueColumn":"c"}""",
            "raw", out _).ShouldBeFalse();

    [Fact]
    public void TryParse_theDeferredQueryModel_rejectsTag()
    {
        // Design §5.4 / P3. Serving it would materialise a node the runtime has no way to read.
        const string json = """{"driver":"Sql","model":"Query","table":"T","query":"SELECT 1"}""";
        SqlEquipmentTagParser.TryParse(json, "raw", out _).ShouldBeFalse();
    }

    [Theory]
    [InlineData("""{"driver":"Sql","model":"KeyValue","keyColumn":"k","keyValue":"v","valueColumn":"c"}""")]
    [InlineData("""{"driver":"Sql","model":"KeyValue","table":"","keyColumn":"k","keyValue":"v","valueColumn":"c"}""")]
    [InlineData("""{"driver":"Sql","model":"KeyValue","table":"  ","keyColumn":"k","keyValue":"v","valueColumn":"c"}""")]
    [InlineData("""{"driver":"Sql","model":"WideRow","columnName":"c","rowSelector":{"topByTimestamp":"ts"}}""")]
    public void TryParse_withoutATable_rejectsTag(string json)
        => SqlEquipmentTagParser.TryParse(json, "raw", out _).ShouldBeFalse();

    [Fact]
    public void TryParse_invalidTypeEnum_rejectsTag()
        => SqlEquipmentTagParser.TryParse(
            """{"driver":"Sql","model":"KeyValue","table":"T","keyColumn":"k","keyValue":"v","valueColumn":"c","type":"Nonsense"}""",
            "raw", out _).ShouldBeFalse();
}
