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

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

/// <summary>
///     Pins the query-mapping core: which tags share a query (the GroupKey), the exact SQL emitted for each
///     group, and — the thing this type exists to guarantee — that <b>every authored value is a bound
///     parameter and never text</b>.
///     <para>GroupKey correctness governs read correctness: too coarse and tags silently read each other's
///     values; too fine and the batching collapses to one query per tag. Both failure modes are asserted
///     here.</para>
/// </summary>
public class SqlGroupPlannerTests
{
    private static SqlTagDefinition KvTag(
        string name, string table, string keyColumn, string keyValue, string valueColumn, string? timestampColumn)
        => new(name, SqlTagModel.KeyValue, table,
            KeyColumn: keyColumn, KeyValue: keyValue, ValueColumn: valueColumn,
            TimestampColumn: timestampColumn);

    private static SqlTagDefinition WideTag(
        string name, string table, string columnName,
        string? selectorColumn = null, string? selectorValue = null,
        string? topByTimestamp = null, string? timestampColumn = null)
        => new(name, SqlTagModel.WideRow, table,
            TimestampColumn: timestampColumn, ColumnName: columnName,
            RowSelectorColumn: selectorColumn, RowSelectorValue: selectorValue,
            RowSelectorTopByTimestamp: topByTimestamp);

    // ---- the plan's golden case ----

    [Fact]
    public void KeyValueTags_sameSource_foldIntoOnePlan_withBoundInList()
    {
        var dialect = new SqlServerDialect();
        var tags = new[]
        {
            KvTag("A", "dbo.TagValues", "tag_name", "Line1.Speed", "num_value", "sample_ts"),
            KvTag("B", "dbo.TagValues", "tag_name", "Line1.Temp", "num_value", "sample_ts"),
        };

        var plans = SqlGroupPlanner.Plan(tags, dialect).ToList();

        plans.Count.ShouldBe(1);
        plans[0].SqlText.ShouldContain("IN (@k0, @k1)");
        plans[0].SqlText.ShouldContain("[tag_name]");
        plans[0].Members.Count.ShouldBe(2);
        plans[0].Parameters.ShouldBe(new object[] { "Line1.Speed", "Line1.Temp" }); // values are params, not text
    }

    [Fact]
    public void KeyValuePlan_emitsTheWholeStatement_keyValueTimestampFromTheQuotedTable()
    {
        var plans = SqlGroupPlanner.Plan(
            [KvTag("A", "dbo.TagValues", "tag_name", "Line1.Speed", "num_value", "sample_ts")],
            new SqlServerDialect());

        plans[0].SqlText.ShouldBe(
            "SELECT [tag_name], [num_value], [sample_ts] FROM [dbo].[TagValues] WHERE [tag_name] IN (@k0)");
        plans[0].ParameterNames.ShouldBe(new[] { "@k0" });
        plans[0].SelectedColumns.ShouldBe(new[] { "tag_name", "num_value", "sample_ts" });
        plans[0].Model.ShouldBe(SqlTagModel.KeyValue);
        plans[0].KeyColumn.ShouldBe("tag_name");
        plans[0].ValueColumn.ShouldBe("num_value");
        plans[0].TimestampColumn.ShouldBe("sample_ts");
    }

    [Fact]
    public void KeyValuePlan_withoutATimestampColumn_omitsItFromTheSelectList()
    {
        var plans = SqlGroupPlanner.Plan(
            [KvTag("A", "dbo.TagValues", "tag_name", "Line1.Speed", "num_value", null)],
            new SqlServerDialect());

        plans[0].SqlText.ShouldBe(
            "SELECT [tag_name], [num_value] FROM [dbo].[TagValues] WHERE [tag_name] IN (@k0)");
        plans[0].TimestampColumn.ShouldBeNull();
    }

    // ---- GroupKey: what must NOT fold together ----

    [Fact]
    public void KeyValueTags_onDifferentTables_produceTwoPlans()
    {
        var plans = SqlGroupPlanner.Plan(
        [
            KvTag("A", "dbo.TagValues", "tag_name", "Line1.Speed", "num_value", "sample_ts"),
            KvTag("B", "dbo.OtherValues", "tag_name", "Line1.Temp", "num_value", "sample_ts"),
        ], new SqlServerDialect()).ToList();

        plans.Count.ShouldBe(2);
        plans[0].GroupKey.ShouldNotBe(plans[1].GroupKey);
        plans[0].SqlText.ShouldContain("[dbo].[TagValues]");
        plans[1].SqlText.ShouldContain("[dbo].[OtherValues]");
    }

    [Fact]
    public void KeyValueTags_onTheSameTableButDifferentValueColumn_produceTwoPlans()
    {
        var plans = SqlGroupPlanner.Plan(
        [
            KvTag("A", "dbo.TagValues", "tag_name", "Line1.Speed", "num_value", "sample_ts"),
            KvTag("B", "dbo.TagValues", "tag_name", "Line1.Temp", "str_value", "sample_ts"),
        ], new SqlServerDialect()).ToList();

        // Folding these would make B read A's column — the silent cross-read this GroupKey prevents.
        plans.Count.ShouldBe(2);
        plans[0].SqlText.ShouldContain("[num_value]");
        plans[1].SqlText.ShouldContain("[str_value]");
    }

    [Fact]
    public void KeyValueTags_onTheSameTableButDifferentTimestampColumn_produceTwoPlans()
    {
        var plans = SqlGroupPlanner.Plan(
        [
            KvTag("A", "dbo.TagValues", "tag_name", "Line1.Speed", "num_value", "sample_ts"),
            KvTag("B", "dbo.TagValues", "tag_name", "Line1.Temp", "num_value", "recorded_at"),
        ], new SqlServerDialect()).ToList();

        plans.Count.ShouldBe(2);
    }

    [Fact]
    public void KeyValueTags_onTheSameTableButDifferentKeyColumn_produceTwoPlans()
    {
        var plans = SqlGroupPlanner.Plan(
        [
            KvTag("A", "dbo.TagValues", "tag_name", "Line1.Speed", "num_value", null),
            KvTag("B", "dbo.TagValues", "alt_name", "Line1.Temp", "num_value", null),
        ], new SqlServerDialect()).ToList();

        plans.Count.ShouldBe(2);
    }

    [Fact]
    public void TagsOfDifferentModels_neverShareAPlan_evenOnTheSameTable()
    {
        var plans = SqlGroupPlanner.Plan(
        [
            KvTag("A", "dbo.Latest", "tag_name", "Line1.Speed", "num_value", null),
            WideTag("B", "dbo.Latest", "oven_temp", selectorColumn: "station_id", selectorValue: "7"),
        ], new SqlServerDialect()).ToList();

        plans.Count.ShouldBe(2);
        plans[0].Model.ShouldBe(SqlTagModel.KeyValue);
        plans[1].Model.ShouldBe(SqlTagModel.WideRow);
    }

    // ---- distinct parameter binding ----

    [Fact]
    public void KeyValueTags_sharingAKeyValue_bindTheKeyOnce_butKeepBothMembers()
    {
        var plans = SqlGroupPlanner.Plan(
        [
            KvTag("A", "dbo.TagValues", "tag_name", "Line1.Speed", "num_value", null),
            KvTag("B", "dbo.TagValues", "tag_name", "Line1.Speed", "num_value", null),
            KvTag("C", "dbo.TagValues", "tag_name", "Line1.Temp", "num_value", null),
        ], new SqlServerDialect()).ToList();

        plans.Count.ShouldBe(1);
        plans[0].Parameters.ShouldBe(new object[] { "Line1.Speed", "Line1.Temp" });
        plans[0].ParameterNames.ShouldBe(new[] { "@k0", "@k1" });
        plans[0].SqlText.ShouldContain("IN (@k0, @k1)");
        // Both tags stay members: two OPC UA nodes are fed from the one row.
        plans[0].Members.Select(m => m.Name).ShouldBe(new[] { "A", "B", "C" });
    }

    [Fact]
    public void ParameterNamesAndParameters_arePositionallyAligned_andMatchTheMarkersInTheSql()
    {
        var plans = SqlGroupPlanner.Plan(
        [
            KvTag("A", "dbo.TagValues", "tag_name", "k0", "num_value", null),
            KvTag("B", "dbo.TagValues", "tag_name", "k1", "num_value", null),
            KvTag("C", "dbo.TagValues", "tag_name", "k2", "num_value", null),
        ], new SqlServerDialect()).ToList();

        var plan = plans[0];
        plan.ParameterNames.Count.ShouldBe(plan.Parameters.Count);
        plan.SqlText.ShouldEndWith("IN (" + string.Join(", ", plan.ParameterNames) + ")");
        plan.Parameters.ShouldBe(new object[] { "k0", "k1", "k2" });
    }

    // ---- wide row ----

    [Fact]
    public void WideRowTags_onTheSameRowSelector_foldIntoOnePlan_listingEachColumnOnce()
    {
        var plans = SqlGroupPlanner.Plan(
        [
            WideTag("A", "dbo.LatestStatus", "oven_temp", selectorColumn: "station_id", selectorValue: "7"),
            WideTag("B", "dbo.LatestStatus", "pressure", selectorColumn: "station_id", selectorValue: "7"),
            WideTag("C", "dbo.LatestStatus", "oven_temp", selectorColumn: "station_id", selectorValue: "7"),
        ], new SqlServerDialect()).ToList();

        plans.Count.ShouldBe(1);
        plans[0].SqlText.ShouldBe(
            "SELECT [oven_temp], [pressure] FROM [dbo].[LatestStatus] WHERE [station_id] = @w");
        plans[0].ParameterNames.ShouldBe(new[] { "@w" });
        plans[0].Parameters.ShouldBe(new object[] { "7" });   // the whereValue is bound, never inlined
        plans[0].SelectedColumns.ShouldBe(new[] { "oven_temp", "pressure" });
        plans[0].Members.Count.ShouldBe(3);
    }

    [Fact]
    public void WideRowTags_onADifferentRowSelectorValue_produceTwoPlans()
    {
        var plans = SqlGroupPlanner.Plan(
        [
            WideTag("A", "dbo.LatestStatus", "oven_temp", selectorColumn: "station_id", selectorValue: "7"),
            WideTag("B", "dbo.LatestStatus", "oven_temp", selectorColumn: "station_id", selectorValue: "8"),
        ], new SqlServerDialect()).ToList();

        // Folding these would publish station 7's temperature on station 8's node.
        plans.Count.ShouldBe(2);
        plans[0].Parameters.ShouldBe(new object[] { "7" });
        plans[1].Parameters.ShouldBe(new object[] { "8" });
    }

    [Fact]
    public void WideRowPlan_appendsEachDistinctMemberTimestampColumnAfterTheValueColumns()
    {
        var plans = SqlGroupPlanner.Plan(
        [
            WideTag("A", "dbo.LatestStatus", "oven_temp",
                selectorColumn: "station_id", selectorValue: "7", timestampColumn: "sample_ts"),
            WideTag("B", "dbo.LatestStatus", "pressure",
                selectorColumn: "station_id", selectorValue: "7", timestampColumn: "sample_ts"),
        ], new SqlServerDialect()).ToList();

        plans.Count.ShouldBe(1);
        plans[0].SelectedColumns.ShouldBe(new[] { "oven_temp", "pressure", "sample_ts" });
        plans[0].SqlText.ShouldBe(
            "SELECT [oven_temp], [pressure], [sample_ts] FROM [dbo].[LatestStatus] WHERE [station_id] = @w");
    }

    [Fact]
    public void WideRowTags_topByTimestamp_emitTheTop1OrderByDescForm_withNoParameters()
    {
        var plans = SqlGroupPlanner.Plan(
        [
            WideTag("A", "dbo.Readings", "oven_temp", topByTimestamp: "sample_ts"),
            WideTag("B", "dbo.Readings", "pressure", topByTimestamp: "sample_ts"),
        ], new SqlServerDialect()).ToList();

        plans.Count.ShouldBe(1);
        plans[0].SqlText.ShouldBe(
            "SELECT TOP 1 [oven_temp], [pressure] FROM [dbo].[Readings] ORDER BY [sample_ts] DESC");
        plans[0].Parameters.ShouldBeEmpty();
        plans[0].ParameterNames.ShouldBeEmpty();
    }

    [Fact]
    public void WideRowTags_topByTimestamp_andAWherePair_onTheSameTable_produceTwoPlans()
    {
        var plans = SqlGroupPlanner.Plan(
        [
            WideTag("A", "dbo.Readings", "oven_temp", topByTimestamp: "sample_ts"),
            WideTag("B", "dbo.Readings", "oven_temp", selectorColumn: "station_id", selectorValue: "7"),
        ], new SqlServerDialect()).ToList();

        plans.Count.ShouldBe(2);
    }

    // ---- identifier quoting ----

    [Fact]
    public void DottedTableName_isQuotedPerPart_notAsOneIdentifier()
    {
        var plans = SqlGroupPlanner.Plan(
            [KvTag("A", "dbo.TagValues", "tag_name", "k", "num_value", null)],
            new SqlServerDialect()).ToList();

        plans[0].SqlText.ShouldContain("[dbo].[TagValues]");
        plans[0].SqlText.ShouldNotContain("[dbo.TagValues]");   // would name a nonexistent object
    }

    [Fact]
    public void UndottedTableName_isQuotedAsASinglePart()
    {
        var plans = SqlGroupPlanner.Plan(
            [KvTag("A", "TagValues", "tag_name", "k", "num_value", null)],
            new SqlServerDialect()).ToList();

        plans[0].SqlText.ShouldContain("FROM [TagValues] ");
    }

    [Fact]
    public void ThreePartTableName_isQuotedPerPart()
    {
        var plans = SqlGroupPlanner.Plan(
            [KvTag("A", "Plant.dbo.TagValues", "tag_name", "k", "num_value", null)],
            new SqlServerDialect()).ToList();

        plans[0].SqlText.ShouldContain("[Plant].[dbo].[TagValues]");
    }

    [Fact]
    public void AnEmptyTableNamePart_isRejected_ratherThanEmitted()
        => Should.Throw<ArgumentException>(() => SqlGroupPlanner.Plan(
            [KvTag("A", "dbo..TagValues", "tag_name", "k", "num_value", null)],
            new SqlServerDialect()));

    // ---- the injection boundary ----

    [Fact]
    public void AHostileKeyValue_isBoundAsAParameter_andNeverReachesTheSqlText()
    {
        const string payload = "'; DROP TABLE x --";

        var plans = SqlGroupPlanner.Plan(
            [KvTag("A", "dbo.TagValues", "tag_name", payload, "num_value", null)],
            new SqlServerDialect()).ToList();

        plans[0].Parameters.ShouldBe(new object[] { payload });
        plans[0].SqlText.ShouldNotContain("DROP");
        plans[0].SqlText.ShouldNotContain("--");
        plans[0].SqlText.ShouldNotContain("'");
    }

    [Fact]
    public void AHostileWideRowSelectorValue_isBoundAsAParameter_andNeverReachesTheSqlText()
    {
        const string payload = "7'); DROP TABLE x --";

        var plans = SqlGroupPlanner.Plan(
            [WideTag("A", "dbo.LatestStatus", "oven_temp",
                selectorColumn: "station_id", selectorValue: payload)],
            new SqlServerDialect()).ToList();

        plans[0].Parameters.ShouldBe(new object[] { payload });
        plans[0].SqlText.ShouldNotContain("DROP");
    }

    [Fact]
    public void AHostileColumnName_goesThroughQuoteIdentifier_ratherThanBeingConcatenatedRaw()
    {
        var plans = SqlGroupPlanner.Plan(
            [KvTag("A", "dbo.T", "tag_name", "k", "v] ; DROP TABLE Users --", null)],
            new SqlServerDialect()).ToList();

        // A column name is an identifier — it cannot be parameterized, so the guarantee is that it is
        // bracket-quoted with ] doubled, leaving one (nonexistent) identifier rather than executable SQL.
        plans[0].SqlText.ShouldBe(
            "SELECT [tag_name], [v]] ; DROP TABLE Users --] FROM [dbo].[T] WHERE [tag_name] IN (@k0)");
    }

    // ---- contract edges ----

    [Fact]
    public void NoTags_yieldNoPlans()
        => SqlGroupPlanner.Plan([], new SqlServerDialect()).ShouldBeEmpty();

    [Fact]
    public void PlanOrder_followsTheInputOrderOfTheFirstMemberOfEachGroup()
    {
        var plans = SqlGroupPlanner.Plan(
        [
            KvTag("A", "dbo.Third", "k", "1", "v", null),
            KvTag("B", "dbo.First", "k", "2", "v", null),
            KvTag("C", "dbo.Third", "k", "3", "v", null),
        ], new SqlServerDialect()).ToList();

        plans.Count.ShouldBe(2);
        plans[0].Members.Select(m => m.Name).ShouldBe(new[] { "A", "C" });
        plans[1].Members.Select(m => m.Name).ShouldBe(new[] { "B" });
    }

    [Fact]
    public void TheDeferredQueryModel_throws_ratherThanBeingSilentlyDropped()
    {
        var tag = new SqlTagDefinition("A", SqlTagModel.Query, "dbo.T");
        Should.Throw<NotSupportedException>(() => SqlGroupPlanner.Plan([tag], new SqlServerDialect()));
    }

    [Fact]
    public void AWideRowTagWithNoRowSelectorAtAll_throws()
    {
        var tag = WideTag("A", "dbo.T", "oven_temp");
        Should.Throw<ArgumentException>(() => SqlGroupPlanner.Plan([tag], new SqlServerDialect()));
    }
}
