using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Configurations;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities;
using AuditOutcome = ZB.MOM.WW.Audit.AuditOutcome;

namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Configurations;

/// <summary>
/// Schema-level tests for the C5 (Task 2.5) <see cref="AuditLogEntityTypeConfiguration"/>.
/// After the central <c>dbo.AuditLog</c> collapse the table is the 10 canonical
/// <c>ZB.MOM.WW.Audit.AuditEvent</c> columns plus six read-only <b>persisted computed
/// columns</b> derived from <c>DetailsJson</c>. Verifies the PK, the canonical/computed
/// property split, the Channel→Category column mapping, the computed-column SQL, and the
/// named indexes — via EF model metadata on the in-memory SQLite test context (the SQLite
/// test context strips the JSON_VALUE computed SQL so EnsureCreated still succeeds; these
/// metadata assertions read the production-shaped model before that strip is observable
/// for column-name/key/index facts).
/// </summary>
public class AuditLogEntityTypeConfigurationTests : IDisposable
{
    private readonly ScadaBridgeDbContext _context;

    public AuditLogEntityTypeConfigurationTests()
    {
        _context = SqliteTestHelper.CreateInMemoryContext();
    }

    public void Dispose()
    {
        _context.Database.CloseConnection();
        _context.Dispose();
    }

    [Fact]
    public void Configure_MapsToAuditLogTable_WithCompositePrimaryKey()
    {
        // Composite PK {EventId, OccurredAtUtc} is required by the partitioned
        // AuditLog table — the clustered key must include the partition column
        // (OccurredAtUtc) so each row can be located in its partition.
        var entity = _context.Model.FindEntityType(typeof(AuditLogRow));

        Assert.NotNull(entity);
        Assert.Equal("AuditLog", entity!.GetTableName());

        var pk = entity.FindPrimaryKey();
        Assert.NotNull(pk);

        var pkPropertyNames = pk!.Properties.Select(p => p.Name).ToArray();
        Assert.Equal(new[] { nameof(AuditLogRow.EventId), nameof(AuditLogRow.OccurredAtUtc) }, pkPropertyNames);
    }

    [Fact]
    public void Configure_DeclaresNoNonAlignedEventIdIndex_UniquenessRidesTheAlignedClusteredKey()
    {
        // WP2.2 (AlignAuditLogEventIdUniqueness): the standalone non-aligned
        // UX_AuditLog_EventId is gone. EventId uniqueness now rides the clustered
        // PK (EventId, OccurredAtUtc), which is partition-aligned on
        // ps_AuditLog_Month — so ALTER TABLE ... SWITCH PARTITION no longer needs
        // an offline index drop/rebuild around every retention purge. EventId is a
        // GUID minted once at the site alongside OccurredAtUtc, so pair-uniqueness
        // is EventId-uniqueness in practice, and the idempotency probe
        // (WHERE EventId = @id) still seeks the clustered key's leading column.
        //
        // Re-declaring a single-column unique index here would silently reinstate
        // the SWITCH incompatibility, so this test pins its absence.
        var entity = _context.Model.FindEntityType(typeof(AuditLogRow));
        Assert.NotNull(entity);

        Assert.DoesNotContain(
            entity!.GetIndexes(),
            i => i.GetDatabaseName() == "UX_AuditLog_EventId");

        var pk = entity.FindPrimaryKey();
        Assert.NotNull(pk);
        Assert.Equal(
            new[] { nameof(AuditLogRow.EventId), nameof(AuditLogRow.OccurredAtUtc) },
            pk!.Properties.Select(p => p.Name).ToArray());
    }

    [Fact]
    public void Configure_HasExpectedPropertyCount()
    {
        var entity = _context.Model.FindEntityType(typeof(AuditLogRow));
        Assert.NotNull(entity);

        var properties = entity!.GetProperties()
            .Where(p => !p.IsShadowProperty())
            .ToList();

        // C5: 10 canonical columns (EventId, OccurredAtUtc, Actor, Action, Outcome,
        // Channel[=Category], Target, SourceNode, CorrelationId, DetailsJson) + 6
        // persisted computed columns (Kind, Status, SourceSiteId, ExecutionId,
        // ParentExecutionId, IngestedAtUtc) = 16.
        Assert.Equal(16, properties.Count);
    }

    [Fact]
    public void Configure_ChannelMapsToCanonicalCategoryColumn()
    {
        // The Channel enum property is stored in the canonical Category column
        // (Category = channel name for ScadaBridge).
        var entity = _context.Model.FindEntityType(typeof(AuditLogRow));
        Assert.NotNull(entity);

        var channel = entity!.FindProperty(nameof(AuditLogRow.Channel));
        Assert.NotNull(channel);
        Assert.Equal("Category", channel!.GetColumnName());
    }

    [Theory]
    [InlineData(nameof(AuditLogRow.Kind), "JSON_VALUE(DetailsJson,'$.kind')")]
    [InlineData(nameof(AuditLogRow.Status), "JSON_VALUE(DetailsJson,'$.status')")]
    [InlineData(nameof(AuditLogRow.SourceSiteId), "JSON_VALUE(DetailsJson,'$.sourceSiteId')")]
    [InlineData(nameof(AuditLogRow.ExecutionId), "CAST(JSON_VALUE(DetailsJson,'$.executionId') AS uniqueidentifier)")]
    [InlineData(nameof(AuditLogRow.ParentExecutionId), "CAST(JSON_VALUE(DetailsJson,'$.parentExecutionId') AS uniqueidentifier)")]
    public void Configure_ComputedColumns_HaveExpectedPersistedSql(string propertyName, string expectedSql)
    {
        // Note: the SQLite test context strips computed-column SQL so EnsureCreated
        // works. Re-build a SQL Server model here to read the production computed SQL.
        using var sqlServerContext = CreateSqlServerModelContext();
        var entity = sqlServerContext.Model.FindEntityType(typeof(AuditLogRow));
        Assert.NotNull(entity);

        var property = entity!.FindProperty(propertyName);
        Assert.NotNull(property);
        Assert.Equal(expectedSql, property!.GetComputedColumnSql());
        // Persisted (stored) computed column.
        Assert.True(property.GetIsStored());
        // Read-only — EF never writes it.
        Assert.Equal(Microsoft.EntityFrameworkCore.Metadata.ValueGenerated.OnAddOrUpdate, property.ValueGenerated);
    }

    [Fact]
    public void Configure_IngestedAtUtc_IsNonPersistedComputedColumn()
    {
        using var sqlServerContext = CreateSqlServerModelContext();
        var entity = sqlServerContext.Model.FindEntityType(typeof(AuditLogRow));
        Assert.NotNull(entity);

        var property = entity!.FindProperty(nameof(AuditLogRow.IngestedAtUtc));
        Assert.NotNull(property);
        Assert.Equal(
            "CAST(SWITCHOFFSET(CAST(JSON_VALUE(DetailsJson,'$.ingestedAtUtc') AS datetimeoffset), 0) AS datetime2(7))",
            property!.GetComputedColumnSql());
        // NON-persisted: the datetimeoffset/SWITCHOFFSET cast is non-deterministic, so
        // SQL Server rejects a PERSISTED column. It is not indexed, so this is fine.
        Assert.False(property.GetIsStored() ?? true);
        Assert.Equal(Microsoft.EntityFrameworkCore.Metadata.ValueGenerated.OnAddOrUpdate, property.ValueGenerated);
    }

    [Fact]
    public void Configure_ExpectedIndexes_WithCorrectNames()
    {
        var entity = _context.Model.FindEntityType(typeof(AuditLogRow));
        Assert.NotNull(entity);

        var indexNames = entity!.GetIndexes()
            .Select(i => i.GetDatabaseName())
            .OrderBy(n => n, StringComparer.Ordinal)
            .ToList();

        // The index NAMES are preserved across the C5 collapse (the column sets move to
        // the canonical/computed shape but the discoverable names do not change).
        var expected = new[]
        {
            "IX_AuditLog_Channel_Status_Occurred",
            "IX_AuditLog_CorrelationId",
            "IX_AuditLog_Execution",
            "IX_AuditLog_Node_Occurred",
            "IX_AuditLog_OccurredAtUtc",
            "IX_AuditLog_ParentExecution",
            "IX_AuditLog_Site_Occurred",
            "IX_AuditLog_Target_Occurred",
            // UX_AuditLog_EventId is intentionally absent — dropped by
            // AlignAuditLogEventIdUniqueness; the aligned clustered PK carries it.
        };

        Assert.Equal(expected, indexNames);
    }

    [Theory]
    [InlineData(nameof(AuditLogRow.Kind))]
    [InlineData(nameof(AuditLogRow.Status))]
    [InlineData(nameof(AuditLogRow.Channel))]
    public void Configure_EnumColumns_StoredAsVarchar32(string propertyName)
    {
        var entity = _context.Model.FindEntityType(typeof(AuditLogRow));
        Assert.NotNull(entity);

        var property = entity!.FindProperty(propertyName);
        Assert.NotNull(property);

        // Enums are converted to strings (varchar(32) IsUnicode=false on SQL Server).
        Assert.Equal(typeof(string), property!.GetProviderClrType() ?? property.ClrType);
        Assert.Equal(32, property.GetMaxLength());
        Assert.False(property.IsUnicode() ?? true);
    }

    [Fact]
    public void Configure_Outcome_StoredAsVarchar16()
    {
        var entity = _context.Model.FindEntityType(typeof(AuditLogRow));
        Assert.NotNull(entity);

        var property = entity!.FindProperty(nameof(AuditLogRow.Outcome));
        Assert.NotNull(property);
        Assert.Equal(typeof(string), property!.GetProviderClrType() ?? property.ClrType);
        Assert.Equal(16, property.GetMaxLength());
        Assert.False(property.IsUnicode() ?? true);
    }

    [Fact]
    public async Task Configure_UtcConverter_HydratesOccurredAtUtcAsKindUtc()
    {
        // Insert an AuditLogRow with an Unspecified-Kind DateTime, then re-read it in
        // a fresh context. The UtcConverter on OccurredAtUtc must re-tag the
        // round-tripped value as DateTimeKind.Utc. Only the canonical columns are
        // written here — the computed columns are read-only (and stripped to plain,
        // always-null columns on the SQLite test model).
        var unspecifiedOccurred = new DateTime(2026, 5, 28, 10, 30, 0, DateTimeKind.Unspecified);
        var eventId = Guid.NewGuid();
        var corr = Guid.NewGuid();

        var evt = new AuditLogRow
        {
            EventId = eventId,
            OccurredAtUtc = unspecifiedOccurred,
            Actor = "system",
            Action = "ApiOutbound.ApiCall",
            Outcome = AuditOutcome.Success,
            Channel = AuditChannel.ApiOutbound,
            CorrelationId = corr,
            DetailsJson = "{\"channel\":\"ApiOutbound\",\"kind\":\"ApiCall\",\"status\":\"Delivered\"}",
        };

        _context.Set<AuditLogRow>().Add(evt);
        await _context.SaveChangesAsync();

        // Detach and re-read so we exercise the hydrate path, not the change tracker.
        _context.ChangeTracker.Clear();

        var loaded = await _context.Set<AuditLogRow>()
            .AsNoTracking()
            .Where(e => e.EventId == eventId)
            .SingleAsync();

        Assert.Equal(DateTimeKind.Utc, loaded.OccurredAtUtc.Kind);
        // The timestamp ticks must round-trip unchanged — the converter only touches
        // the Kind flag, not the wall-clock value.
        Assert.Equal(unspecifiedOccurred.Ticks, loaded.OccurredAtUtc.Ticks);
    }

    [Fact]
    public void Configure_OccurredAtUtcAndIngestedAtUtc_HaveUtcValueConverters()
    {
        // Model-metadata cross-check on the converter wiring — guards against a
        // future config refactor accidentally removing the HasConversion calls.
        var entity = _context.Model.FindEntityType(typeof(AuditLogRow));
        Assert.NotNull(entity);

        var occurredAt = entity!.FindProperty(nameof(AuditLogRow.OccurredAtUtc));
        Assert.NotNull(occurredAt);
        Assert.NotNull(occurredAt!.GetValueConverter());

        var ingestedAt = entity.FindProperty(nameof(AuditLogRow.IngestedAtUtc));
        Assert.NotNull(ingestedAt);
        Assert.NotNull(ingestedAt!.GetValueConverter());
    }

    [Fact]
    public void Configure_FilteredIndexes_HaveExpectedFilters()
    {
        var entity = _context.Model.FindEntityType(typeof(AuditLogRow));
        Assert.NotNull(entity);

        var correlationIdx = entity!.GetIndexes()
            .Single(i => i.GetDatabaseName() == "IX_AuditLog_CorrelationId");
        Assert.Equal("[CorrelationId] IS NOT NULL", correlationIdx.GetFilter());

        var targetIdx = entity.GetIndexes()
            .Single(i => i.GetDatabaseName() == "IX_AuditLog_Target_Occurred");
        Assert.Equal("[Target] IS NOT NULL", targetIdx.GetFilter());

        // ExecutionId / ParentExecutionId are computed columns — SQL Server forbids a
        // filtered-index predicate on a computed column, so these indexes are UNFILTERED.
        var executionIdx = entity.GetIndexes()
            .Single(i => i.GetDatabaseName() == "IX_AuditLog_Execution");
        Assert.Null(executionIdx.GetFilter());

        var parentExecutionIdx = entity.GetIndexes()
            .Single(i => i.GetDatabaseName() == "IX_AuditLog_ParentExecution");
        Assert.Null(parentExecutionIdx.GetFilter());
    }

    /// <summary>
    /// Builds a model-only DbContext on the SQL Server provider so the production
    /// computed-column SQL (which the SQLite test context strips) can be asserted.
    /// No database connection is opened — only the model is materialised.
    /// </summary>
    private static ScadaBridgeDbContext CreateSqlServerModelContext()
    {
        var options = new DbContextOptionsBuilder<ScadaBridgeDbContext>()
            .UseSqlServer("Server=unused;Database=unused;TrustServerCertificate=true")
            .Options;
        return new ScadaBridgeDbContext(options);
    }
}
