using Microsoft.Data.SqlClient;
using Shouldly;
using Xunit;

namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;

/// <summary>
///     Introspects the applied schema via <c>sys.*</c> / <c>INFORMATION_SCHEMA.*</c> to confirm that
///     the Fluent-API DbContext produces the exact structure specified in
///     <c>docs/v2/config-db-schema.md</c>. Any change here is a deliberate decision — update the
///     schema doc first, then these tests.
/// </summary>
[Trait("Category", "SchemaCompliance")]
[Collection(nameof(SchemaComplianceCollection))]
public sealed class SchemaComplianceTests
{
    private readonly SchemaComplianceFixture _fixture;

    /// <summary>Initializes a new instance of the <see cref="SchemaComplianceTests"/> class.</summary>
    /// <param name="fixture">The database schema compliance fixture.</param>
    public SchemaComplianceTests(SchemaComplianceFixture fixture) => _fixture = fixture;

    /// <summary>Verifies that all expected tables exist in the schema.</summary>
    [Fact]
    public void All_expected_tables_exist()
    {
        var expected = new[]
        {
            "ServerCluster", "ClusterNode", "ClusterNodeCredential",
            "ConfigAuditLog",
            "UnsArea", "UnsLine",
            // v3 Raw-tree tables (Namespace retired; RawFolder/TagGroup/UnsTagReference added)
            "RawFolder", "TagGroup", "UnsTagReference",
            "DriverInstance", "Device", "Equipment", "Tag", "PollGroup", "VirtualTag",
            "NodeAcl", "ExternalIdReservation",
            "DriverHostStatus",
            "DriverInstanceResilienceStatus",
            "LdapGroupRoleMapping",
            // v3 dropped the orphaned EquipmentImportBatch / EquipmentImportRow tables.
            // ScriptedAlarmState was dropped in per-cluster mesh Phase 4 (Task 9) — scripted-alarm
            // condition state moved to the replicated LocalDb store.
            "Script", "ScriptedAlarm",
            // v2 deploy-model tables (Phase 1 of Akka + fused-hosting alignment)
            "Deployment", "NodeDeploymentState", "ConfigEdit", "DataProtectionKeys",
        };

        var actual = QueryStrings(@"
SELECT name FROM sys.tables WHERE name <> '__EFMigrationsHistory' ORDER BY name;").ToHashSet();

        foreach (var table in expected)
            actual.ShouldContain(table, $"missing table: {table}");

        actual.Count.ShouldBe(expected.Length);
    }

    /// <summary>Verifies that filtered unique indexes match the schema specification.</summary>
    [Fact]
    public void Filtered_unique_indexes_match_schema_spec()
    {
        // (IndexName, Filter, Uniqueness) tuples — from OtOpcUaConfigDbContext Fluent config.
        // Kept here as a spec-level source of truth; the test ensures EF generated them verbatim.
        var expected = new[]
        {
            ("UX_ClusterNodeCredential_Value", "([Enabled]=(1))"),
            ("UX_ExternalIdReservation_KindValue_Active", "([ReleasedAt] IS NULL)"),
            // v3 sibling-name filtered unique indexes over nullable parent columns.
            ("UX_RawFolder_Parent_Name", "([ParentRawFolderId] IS NOT NULL)"),
            ("UX_RawFolder_ClusterRoot_Name", "([ParentRawFolderId] IS NULL)"),
            ("UX_DriverInstance_Folder_Name", "([RawFolderId] IS NOT NULL)"),
            ("UX_DriverInstance_ClusterRoot_Name", "([RawFolderId] IS NULL)"),
            ("UX_TagGroup_Parent_Name", "([ParentTagGroupId] IS NOT NULL)"),
            ("UX_TagGroup_DeviceRoot_Name", "([ParentTagGroupId] IS NULL)"),
            ("UX_Tag_Group_Name", "([TagGroupId] IS NOT NULL)"),
            ("UX_Tag_Device_Name", "([TagGroupId] IS NULL)"),
        };

        var rows = QueryRows(@"
SELECT i.name AS IndexName, i.filter_definition
FROM   sys.indexes i
WHERE  i.is_unique = 1 AND i.has_filter = 1;",
            r => (Name: r.GetString(0), Filter: r.IsDBNull(1) ? null : r.GetString(1)));

        foreach (var (name, filter) in expected)
        {
            var match = rows.FirstOrDefault(x => x.Name == name);
            match.Name.ShouldBe(name, $"missing filtered unique index: {name}");
            NormalizeFilter(match.Filter).ShouldBe(NormalizeFilter(filter),
                $"filter predicate for {name} drifted");
        }
    }

    /// <summary>Verifies that check constraints match the schema specification.</summary>
    [Fact]
    public void Check_constraints_match_schema_spec()
    {
        var expected = new[]
        {
            "CK_ServerCluster_RedundancyMode_NodeCount",
            "CK_Device_DeviceConfig_IsJson",
            "CK_DriverInstance_DriverConfig_IsJson",
            "CK_DriverInstance_ResilienceConfig_IsJson",
            "CK_PollGroup_IntervalMs_Min",
            "CK_Tag_TagConfig_IsJson",
            "CK_ConfigAuditLog_DetailsJson_IsJson",
            "CK_ConfigEdit_FieldsJson_IsJson",
        };

        var actual = QueryStrings("SELECT name FROM sys.check_constraints ORDER BY name;").ToHashSet();

        foreach (var ck in expected)
            actual.ShouldContain(ck, $"missing CHECK constraint: {ck}");
    }

    /// <summary>Verifies that JSON check constraints use the ISJSON function.</summary>
    [Fact]
    public void Json_check_constraints_use_IsJson_function()
    {
        var rows = QueryRows(@"
SELECT cc.name, cc.definition
FROM   sys.check_constraints cc
WHERE  cc.name LIKE 'CK_%_IsJson';",
            r => (Name: r.GetString(0), Definition: r.GetString(1)));

        rows.Count.ShouldBeGreaterThanOrEqualTo(4);

        foreach (var (name, definition) in rows)
            definition.ShouldContain("isjson(", Case.Insensitive,
                $"{name} definition does not call ISJSON: {definition}");
    }

    /// <summary>Verifies that the Deployment Status column exists.</summary>
    [Fact]
    public void Deployment_Status_column_exists()
    {
        // v2 replaces ConfigGeneration with Deployment. Storage type for Status is design-defined
        // (currently int via the EF enum mapping); the invariant we care about is that the
        // column is present.
        var rows = QueryRows(@"
SELECT c.COLUMN_NAME, c.DATA_TYPE
FROM   INFORMATION_SCHEMA.COLUMNS c
WHERE  c.TABLE_NAME = 'Deployment' AND c.COLUMN_NAME = 'Status';",
            r => (Column: r.GetString(0), Type: r.GetString(1)));

        rows.Count.ShouldBe(1);
    }

    /// <summary>Verifies that Equipment carries OPC 4.0 10 identity fields.</summary>
    [Fact]
    public void Equipment_carries_Opc40010_identity_fields()
    {
        var columns = QueryStrings(@"
SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'Equipment';")
            .ToHashSet(StringComparer.OrdinalIgnoreCase);

        foreach (var col in new[]
                 {
                     "EquipmentUuid", "EquipmentId", "MachineCode", "ZTag", "SAPID",
                     "Manufacturer", "Model", "SerialNumber",
                 })
            columns.ShouldContain(col, $"Equipment missing expected column: {col}");
    }

    /// <summary>Verifies the retired v2 tables (Namespace, EquipmentImportBatch/Row) are absent.</summary>
    [Fact]
    public void Retired_v2_tables_are_absent()
    {
        var tables = QueryStrings("SELECT name FROM sys.tables;").ToHashSet();
        tables.ShouldNotContain("Namespace");
        tables.ShouldNotContain("EquipmentImportBatch");
        tables.ShouldNotContain("EquipmentImportRow");
    }

    /// <summary>Verifies the new v3 Raw-tree tables each carry their logical-id unique index.</summary>
    [Fact]
    public void V3_raw_tree_tables_have_logical_id_unique_indexes()
    {
        var expected = new[]
        {
            ("RawFolder", "UX_RawFolder_LogicalId"),
            ("TagGroup", "UX_TagGroup_LogicalId"),
            ("UnsTagReference", "UX_UnsTagReference_LogicalId"),
        };

        foreach (var (table, index) in expected)
        {
            var indexes = QueryStrings($@"
SELECT i.name
FROM   sys.indexes i
JOIN   sys.tables  t ON i.object_id = t.object_id
WHERE  t.name = '{table}' AND i.is_unique = 1;").ToHashSet();
            indexes.ShouldContain(index, $"{table} missing unique index {index}");
        }
    }

    private List<string> QueryStrings(string sql)
    {
        using var conn = _fixture.OpenConnection();
        using var cmd = new SqlCommand(sql, conn);
        using var reader = cmd.ExecuteReader();
        var result = new List<string>();
        while (reader.Read())
            result.Add(reader.GetString(0));
        return result;
    }

    private List<T> QueryRows<T>(string sql, Func<SqlDataReader, T> project)
    {
        using var conn = _fixture.OpenConnection();
        using var cmd = new SqlCommand(sql, conn);
        using var reader = cmd.ExecuteReader();
        var result = new List<T>();
        while (reader.Read())
            result.Add(project(reader));
        return result;
    }

    private static string? NormalizeFilter(string? filter) =>
        filter?.Replace(" ", string.Empty).Replace("(", string.Empty).Replace(")", string.Empty).ToLowerInvariant();
}
