using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities;

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

/// <summary>
/// Maps the <see cref="AuditLogRow"/> persistence shape to the central
/// <c>dbo.AuditLog</c> table: the 10 canonical <c>ZB.MOM.WW.Audit.AuditEvent</c> columns
/// (writable) plus six read-only, server-side <b>computed columns</b> derived from
/// <c>DetailsJson</c> via <c>JSON_VALUE</c> — five PERSISTED (<c>Kind</c>/<c>Status</c>/
/// <c>SourceSiteId</c>/<c>ExecutionId</c>/<c>ParentExecutionId</c>) plus <c>IngestedAtUtc</c>
/// which is computed but NOT persisted (SQL Server forbids PERSISTED on its non-deterministic
/// <c>SWITCHOFFSET</c> cast). The computed-column SQL and the index
/// set here mirror the <c>CollapseAuditLogToCanonical</c> migration's
/// <c>dbo.AuditLog_v2</c> DDL byte-for-byte — keep them in sync.
/// </summary>
public class AuditLogEntityTypeConfiguration : IEntityTypeConfiguration<AuditLogRow>
{
    // SQL Server's datetime2 provider strips the DateTimeKind flag on the wire
    // (a column hydrated from the database always surfaces as
    // DateTimeKind.Unspecified). Without a converter, downstream code that
    // calls .ToLocalTime() / .ToUniversalTime() on an OccurredAtUtc value would
    // silently re-interpret it as local time. These converters force the Kind
    // back to Utc on read, and re-stamp Utc on write so a producer that hands
    // EF a DateTime literal with Kind=Unspecified still lands a UTC-tagged
    // value in the model cache (CLAUDE.md: "All timestamps are UTC throughout
    // the system."). Applied to every DateTime property whose name ends in
    // `Utc`; DateTimeOffset columns already carry their own offset and are NOT
    // routed through these converters.
    private static readonly ValueConverter<DateTime, DateTime> UtcConverter = new(
        v => v.Kind == DateTimeKind.Utc ? v : DateTime.SpecifyKind(v, DateTimeKind.Utc),
        v => DateTime.SpecifyKind(v, DateTimeKind.Utc));

    private static readonly ValueConverter<DateTime?, DateTime?> NullableUtcConverter = new(
        v => v.HasValue
            ? (v.Value.Kind == DateTimeKind.Utc ? v.Value : DateTime.SpecifyKind(v.Value, DateTimeKind.Utc))
            : null,
        v => v.HasValue ? DateTime.SpecifyKind(v.Value, DateTimeKind.Utc) : null);

    // The computed columns derive enum-named strings from DetailsJson (e.g.
    // JSON_VALUE(...,'$.kind') == "CachedResolve"), exactly the value
    // HasConversion<string>() expects on read. The repository never writes these
    // (they are server-computed), but the string<->enum converter is still
    // required so EF materialises them as the strongly-typed enum a LINQ
    // predicate like `e.Kind == AuditKind.CachedResolve` translates against.

    /// <summary>Applies the EF Core type configuration for <see cref="AuditLogRow"/> to the model builder.</summary>
    /// <param name="builder">The entity type builder to configure.</param>
    public void Configure(EntityTypeBuilder<AuditLogRow> builder)
    {
        builder.ToTable("AuditLog");

        // ── Canonical columns (writable) ─────────────────────────────────────
        //
        // Column SQL TYPES are intentionally left to EF's relational conventions
        // (driven by HasMaxLength / IsUnicode / the CLR type) rather than pinned
        // with HasColumnType, so the SAME configuration maps to SQL Server
        // (varchar(n) / nvarchar(n) / uniqueidentifier / datetime2) in production
        // AND to the SQLite test provider (TEXT) without a `(max)`/`uniqueidentifier`
        // literal leaking into SQLite DDL. The migration's raw DDL pins the exact
        // SQL Server types; EF's conventions agree with them (verified clean via
        // `has-pending-model-changes`).

        // Enforce DateTimeKind.Utc on the OccurredAtUtc column. See the
        // UtcConverter remarks above for the rationale.
        builder.Property(e => e.OccurredAtUtc)
            .HasConversion(UtcConverter);

        builder.Property(e => e.Actor)
            .HasMaxLength(256);

        builder.Property(e => e.Action)
            .HasMaxLength(64)
            .IsUnicode(false)
            .IsRequired();

        builder.Property(e => e.Outcome)
            .HasConversion<string>()
            .HasMaxLength(16)
            .IsUnicode(false)
            .IsRequired();

        // Channel rides in the canonical Category column (Category = channel name
        // for ScadaBridge). Stored as the enum's name in varchar(32); the
        // string<->enum converter lets `e.Channel == AuditChannel.X` translate to
        // `[Category] = 'X'` server-side.
        builder.Property(e => e.Channel)
            .HasColumnName("Category")
            .HasConversion<string>()
            .HasMaxLength(32)
            .IsUnicode(false)
            .IsRequired();

        builder.Property(e => e.Target)
            .HasMaxLength(256);

        // SourceNode (SourceNode-stamping): node-local identifier of the
        // cluster member that produced the row (e.g. "node-a", "central-a"). NULL is
        // valid for reconciled rows from a retired node and for direct-write rows
        // produced before this feature shipped. ASCII — varchar(64), no unicode.
        builder.Property(e => e.SourceNode)
            .HasMaxLength(64)
            .IsUnicode(false);

        // DetailsJson: unbounded → nvarchar(max) on SQL Server, TEXT on SQLite.
        // (No HasMaxLength / HasColumnType — let conventions pick per provider.)

        // ── Persisted computed columns (read-only; derived from DetailsJson) ──
        //
        // Each is `… AS <expr> PERSISTED`; EF must never attempt to write them
        // (ValueGeneratedOnAddOrUpdate + metadata-only mapping). The SQL strings
        // here MUST match the migration's dbo.AuditLog_v2 DDL exactly so
        // `dotnet ef migrations has-pending-model-changes` stays clean. The SQLite
        // test context strips the computed-column SQL (JSON_VALUE is unknown to
        // SQLite) so EnsureCreated still works.

        builder.Property(e => e.Kind)
            .HasConversion<string>()
            .HasMaxLength(32)
            .IsUnicode(false)
            .HasComputedColumnSql("JSON_VALUE(DetailsJson,'$.kind')", stored: true)
            .ValueGeneratedOnAddOrUpdate()
            .IsRequired();

        builder.Property(e => e.Status)
            .HasConversion<string>()
            .HasMaxLength(32)
            .IsUnicode(false)
            .HasComputedColumnSql("JSON_VALUE(DetailsJson,'$.status')", stored: true)
            .ValueGeneratedOnAddOrUpdate()
            .IsRequired();

        builder.Property(e => e.SourceSiteId)
            .HasMaxLength(64)
            .IsUnicode(false)
            .HasComputedColumnSql("JSON_VALUE(DetailsJson,'$.sourceSiteId')", stored: true)
            .ValueGeneratedOnAddOrUpdate();

        builder.Property(e => e.ExecutionId)
            .HasComputedColumnSql("CAST(JSON_VALUE(DetailsJson,'$.executionId') AS uniqueidentifier)", stored: true)
            .ValueGeneratedOnAddOrUpdate();

        builder.Property(e => e.ParentExecutionId)
            .HasComputedColumnSql("CAST(JSON_VALUE(DetailsJson,'$.parentExecutionId') AS uniqueidentifier)", stored: true)
            .ValueGeneratedOnAddOrUpdate();

        // IngestedAtUtc rides in DetailsJson as an ISO-8601-with-offset string
        // (always +00:00 — the codec normalises to UTC). SWITCHOFFSET(...,0)
        // normalises any offset to UTC before the datetime2 cast, so the column
        // is the UTC wall-clock regardless. The datetimeoffset cast / SWITCHOFFSET
        // is NON-DETERMINISTic to SQL Server, so this computed column is NOT
        // persisted (stored:false) — a PERSISTED non-deterministic column is
        // rejected at CREATE. It is not indexed, so non-persistence costs nothing.
        // Routed through the nullable UTC converter so the materialised value
        // carries Kind=Utc.
        //
        // NEVER use IngestedAtUtc in a WHERE predicate — it is non-persisted
        // (stored:false), so any filter on it forces a full-table JSON_VALUE parse of
        // DetailsJson on every row (no index can back it). Read-side projection only
        // (arch-review 04, P7). Filter on OccurredAtUtc (the partition-aligned clustered
        // key) instead.
        builder.Property(e => e.IngestedAtUtc)
            .HasColumnType("datetime2(7)")
            .HasConversion(NullableUtcConverter)
            .HasComputedColumnSql(
                "CAST(SWITCHOFFSET(CAST(JSON_VALUE(DetailsJson,'$.ingestedAtUtc') AS datetimeoffset), 0) AS datetime2(7))",
                stored: false)
            .ValueGeneratedOnAddOrUpdate();

        // ── Keys + indexes ───────────────────────────────────────────────────

        // Composite PK includes OccurredAtUtc — required by the monthly partition scheme
        // (ps_AuditLog_Month) so the clustered key is partition-aligned. It is ALSO the
        // only uniqueness enforcement the ingest path needs: EventId is a GUID minted
        // once at the emitting site and never re-stamped, so a given EventId always
        // arrives with the same OccurredAtUtc and can only ever land in one partition.
        // Uniqueness of the pair is therefore uniqueness of EventId in practice, and the
        // idempotency probe (WHERE EventId = @id) still seeks the clustered key's leading
        // column. The predecessor non-aligned UX_AuditLog_EventId on [PRIMARY] was
        // dropped by AlignAuditLogEventIdUniqueness — it existed only to give
        // single-column uniqueness and its non-alignment forced an offline drop/rebuild
        // around every partition-switch purge.
        builder.HasKey(e => new { e.EventId, e.OccurredAtUtc });

        // Index names are locked for reconciliation/migration discoverability. The
        // column SETS migrate to the canonical/computed shape (alog.md §4 semantics
        // preserved): Channel→Category, Site/Node/Execution/ParentExecution now read
        // off the computed columns.
        // INCLUDE(Status) makes the audit KPI window query (count/aggregate by Status
        // over a recent OccurredAtUtc range) a covering index seek — the persisted
        // computed Status column rides along in the leaf without a key/RID lookup back
        // into the clustered index per matching row (arch-review WP1.4).
        builder.HasIndex(e => e.OccurredAtUtc)
            .IsDescending(true)
            .HasDatabaseName("IX_AuditLog_OccurredAtUtc")
            .IncludeProperties(nameof(AuditLogRow.Status));

        builder.HasIndex(e => new { e.SourceSiteId, e.OccurredAtUtc })
            .IsDescending(false, true)
            .HasDatabaseName("IX_AuditLog_Site_Occurred");

        builder.HasIndex(e => e.CorrelationId)
            .HasFilter("[CorrelationId] IS NOT NULL")
            .HasDatabaseName("IX_AuditLog_CorrelationId");

        // ExecutionId / ParentExecutionId are persisted computed columns: SQL Server
        // forbids a filtered-index WHERE predicate referencing a computed column, so
        // these two indexes are UNFILTERED (they also index NULL rows; equality
        // lookups are unaffected). Keep HasFilter(null) so the model matches the
        // migration DDL exactly.
        builder.HasIndex(e => e.ExecutionId)
            .HasDatabaseName("IX_AuditLog_Execution");

        builder.HasIndex(e => e.ParentExecutionId)
            .HasDatabaseName("IX_AuditLog_ParentExecution");

        builder.HasIndex(e => new { e.SourceNode, e.OccurredAtUtc })
            .HasDatabaseName("IX_AuditLog_Node_Occurred");

        // IX_AuditLog_Channel_Status_Occurred name preserved; columns are now the
        // canonical Category (= channel) + computed Status + OccurredAtUtc.
        builder.HasIndex(e => new { e.Channel, e.Status, e.OccurredAtUtc })
            .IsDescending(false, false, true)
            .HasDatabaseName("IX_AuditLog_Channel_Status_Occurred");

        builder.HasIndex(e => new { e.Target, e.OccurredAtUtc })
            .IsDescending(false, true)
            .HasFilter("[Target] IS NOT NULL")
            .HasDatabaseName("IX_AuditLog_Target_Occurred");
    }
}
