using Microsoft.Data.SqlClient;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;

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

/// <summary>
/// C7 (Task 2.5) data-projection tests for the <c>CollapseAuditLogToCanonical</c>
/// migration. Verifies that the canonical column layout and the five persisted computed
/// columns are correct after the migration has been applied:
/// <list type="bullet">
///   <item><c>Action</c> = "{Channel}.{Kind}" per <see cref="AuditFieldBuilders.BuildAction"/>.</item>
///   <item><c>Category</c> = Channel name per <see cref="AuditFieldBuilders.BuildCategory"/>.</item>
///   <item><c>Outcome</c> derived via <see cref="AuditOutcomeProjector.Project"/>:
///     <c>InboundAuthFailure</c> → <c>Denied</c>;
///     Status ∈ {<c>Failed</c>, <c>Parked</c>, <c>Discarded</c>} → <c>Failure</c>;
///     else → <c>Success</c>.</item>
///   <item>Empty <c>Actor</c> maps to NULL.</item>
///   <item><c>DetailsJson</c> produced by <see cref="AuditDetailsCodec.Serialize"/> round-trips
///     correctly and the five persisted computed columns (<c>Kind</c>, <c>Status</c>,
///     <c>SourceSiteId</c>, <c>ExecutionId</c>, <c>ParentExecutionId</c>) resolve to
///     the expected values via <c>JSON_VALUE</c> (<c>IngestedAtUtc</c> is computed but
///     non-persisted, so not asserted here).</item>
/// </list>
/// </summary>
/// <remarks>
/// <para>
/// The fixture applies the FULL migration history (via
/// <see cref="MsSqlMigrationFixture"/>), so this test exercises the post-migration
/// canonical table shape. Rather than using a two-phase fixture (apply up to C4,
/// seed, apply C5), the tests insert rows directly into the canonical table using
/// <see cref="AuditDetailsCodec"/> — the same codec the migration's
/// <c>FOR JSON PATH, WITHOUT_ARRAY_WRAPPER</c> projection is designed to match —
/// and verify that the five persisted computed columns resolve correctly from <c>DetailsJson</c>.
/// This validates the computed-column SQL expressions that are the source of truth
/// for both the live table and <c>SwitchOutPartitionAsync</c>'s staging table.
/// </para>
/// <para>
/// Tests use <see cref="SkippableFactAttribute"/> + <c>Skip.IfNot(...)</c> so the
/// runner reports them as Skipped (not Passed) when MSSQL is unreachable.
/// </para>
/// </remarks>
public class CollapseAuditLogToCanonicalMigrationTests : IClassFixture<MsSqlMigrationFixture>
{
    private readonly MsSqlMigrationFixture _fixture;

    public CollapseAuditLogToCanonicalMigrationTests(MsSqlMigrationFixture fixture)
    {
        _fixture = fixture;
    }

    /// <summary>
    /// A representative ApiOutbound.ApiCall row with populated domain fields.
    /// Verifies: Action, Category, Outcome projection; DetailsJson round-trip;
    /// Kind/Status/SourceSiteId/ExecutionId/ParentExecutionId computed columns.
    /// </summary>
    [SkippableFact]
    public async Task CanonicalRow_ApiOutbound_ComputedColumns_ResolveCorrectly()
    {
        Skip.IfNot(_fixture.Available, _fixture.SkipReason);

        var eventId = Guid.NewGuid();
        var executionId = Guid.NewGuid();
        var parentExecutionId = Guid.NewGuid();
        var occurredAt = new DateTime(2026, 6, 1, 12, 0, 0, DateTimeKind.Utc);

        var details = new AuditDetails
        {
            Channel = AuditChannel.ApiOutbound.ToString(),
            Kind = AuditKind.ApiCall.ToString(),
            Status = AuditStatus.Delivered.ToString(),
            ExecutionId = executionId,
            ParentExecutionId = parentExecutionId,
            SourceSiteId = "site-alpha",
            HttpStatus = 200,
            DurationMs = 42,
            RequestSummary = "{\"body\":\"hello\"}",
            PayloadTruncated = false,
        };
        var detailsJson = AuditDetailsCodec.Serialize(details);

        var action = AuditFieldBuilders.BuildAction(AuditChannel.ApiOutbound, AuditKind.ApiCall);
        var category = AuditFieldBuilders.BuildCategory(AuditChannel.ApiOutbound);
        // AuditStatus.Delivered → Outcome "Success" (not InboundAuthFailure, not Failed/Parked/Discarded)
        var outcome = AuditOutcomeProjector.Project(AuditStatus.Delivered, AuditKind.ApiCall);
        var outcomeStr = outcome.ToString(); // "Success"

        await InsertCanonicalRowAsync(
            eventId, occurredAt,
            actor: "svc-account",
            action: action,
            outcome: outcomeStr,
            category: category,
            target: "ext-api.endpoint",
            sourceNode: "node-a",
            correlationId: null,
            detailsJson: detailsJson);

        // ── Verify canonical top-level columns ─────────────────────────────
        var row = await ReadCanonicalRowAsync(eventId, occurredAt);
        Assert.NotNull(row);

        Assert.Equal(action, row.Action);     // "ApiOutbound.ApiCall"
        Assert.Equal(category, row.Category); // "ApiOutbound"
        Assert.Equal("Success", row.Outcome); // Delivered → Success
        Assert.Equal("svc-account", row.Actor);

        // ── Verify persisted computed columns (JSON_VALUE expressions) ──────
        Assert.Equal(AuditKind.ApiCall.ToString(), row.Kind);
        Assert.Equal(AuditStatus.Delivered.ToString(), row.Status);
        Assert.Equal("site-alpha", row.SourceSiteId);
        Assert.Equal(executionId, row.ExecutionId);
        Assert.Equal(parentExecutionId, row.ParentExecutionId);

        // ── Verify DetailsJson round-trips through codec ────────────────────
        var roundTripped = AuditDetailsCodec.Deserialize(row.DetailsJson);
        Assert.Equal(AuditChannel.ApiOutbound.ToString(), roundTripped.Channel);
        Assert.Equal(AuditKind.ApiCall.ToString(), roundTripped.Kind);
        Assert.Equal(AuditStatus.Delivered.ToString(), roundTripped.Status);
        Assert.Equal("site-alpha", roundTripped.SourceSiteId);
        Assert.Equal(executionId, roundTripped.ExecutionId);
        Assert.Equal(parentExecutionId, roundTripped.ParentExecutionId);
        Assert.Equal(200, roundTripped.HttpStatus);
        Assert.Equal(42, roundTripped.DurationMs);
        Assert.False(roundTripped.PayloadTruncated);
    }

    /// <summary>
    /// An <c>InboundAuthFailure</c> row (channel = <c>ApiInbound</c>).
    /// The C5 migration projection rule: Kind=<c>InboundAuthFailure</c> → Outcome=<c>Denied</c>
    /// regardless of Status. Verifies this special-case projection is represented
    /// correctly in the canonical Outcome column.
    /// </summary>
    [SkippableFact]
    public async Task CanonicalRow_InboundAuthFailure_Outcome_IsDenied()
    {
        Skip.IfNot(_fixture.Available, _fixture.SkipReason);

        var eventId = Guid.NewGuid();
        var occurredAt = new DateTime(2026, 6, 1, 13, 0, 0, DateTimeKind.Utc);

        var details = new AuditDetails
        {
            Channel = AuditChannel.ApiInbound.ToString(),
            Kind = AuditKind.InboundAuthFailure.ToString(),
            Status = AuditStatus.Failed.ToString(),
            PayloadTruncated = false,
        };
        var detailsJson = AuditDetailsCodec.Serialize(details);

        var action = AuditFieldBuilders.BuildAction(AuditChannel.ApiInbound, AuditKind.InboundAuthFailure);
        var category = AuditFieldBuilders.BuildCategory(AuditChannel.ApiInbound);
        // InboundAuthFailure → Denied regardless of Status=Failed.
        var outcome = AuditOutcomeProjector.Project(AuditStatus.Failed, AuditKind.InboundAuthFailure);
        Assert.Equal("Denied", outcome.ToString()); // pre-condition: factory applies same rule

        await InsertCanonicalRowAsync(
            eventId, occurredAt,
            actor: null,                  // unauthenticated — Actor NULL
            action: action,
            outcome: outcome.ToString(),  // "Denied"
            category: category,
            target: "/api/route",
            sourceNode: "central-a",
            correlationId: null,
            detailsJson: detailsJson);

        var row = await ReadCanonicalRowAsync(eventId, occurredAt);
        Assert.NotNull(row);

        Assert.Equal("Denied", row.Outcome);
        Assert.Null(row.Actor);   // NULL Actor preserved

        // Computed columns
        Assert.Equal(AuditKind.InboundAuthFailure.ToString(), row.Kind);
        Assert.Equal(AuditStatus.Failed.ToString(), row.Status);
    }

    /// <summary>
    /// An <c>ApiOutbound.ApiCall</c> row with <c>Status=Failed</c>.
    /// Projection rule: Status ∈ {Failed, Parked, Discarded} → Outcome=<c>Failure</c>.
    /// Verifies the failure-branch projection that applies to non-auth failures.
    /// </summary>
    [SkippableFact]
    public async Task CanonicalRow_FailedStatus_Outcome_IsFailure()
    {
        Skip.IfNot(_fixture.Available, _fixture.SkipReason);

        var eventId = Guid.NewGuid();
        var occurredAt = new DateTime(2026, 6, 1, 14, 0, 0, DateTimeKind.Utc);

        var details = new AuditDetails
        {
            Channel = AuditChannel.ApiOutbound.ToString(),
            Kind = AuditKind.ApiCall.ToString(),
            Status = AuditStatus.Failed.ToString(),
            ErrorMessage = "connection refused",
            PayloadTruncated = false,
        };
        var detailsJson = AuditDetailsCodec.Serialize(details);

        var outcome = AuditOutcomeProjector.Project(AuditStatus.Failed, AuditKind.ApiCall);
        Assert.Equal("Failure", outcome.ToString()); // pre-condition

        await InsertCanonicalRowAsync(
            eventId, occurredAt,
            actor: "svc-account",
            action: AuditFieldBuilders.BuildAction(AuditChannel.ApiOutbound, AuditKind.ApiCall),
            outcome: outcome.ToString(),
            category: AuditFieldBuilders.BuildCategory(AuditChannel.ApiOutbound),
            target: "ext-api.endpoint",
            sourceNode: "node-b",
            correlationId: null,
            detailsJson: detailsJson);

        var row = await ReadCanonicalRowAsync(eventId, occurredAt);
        Assert.NotNull(row);

        Assert.Equal("Failure", row.Outcome);
        Assert.Equal(AuditStatus.Failed.ToString(), row.Status);

        // DetailsJson round-trip preserves ErrorMessage
        var roundTripped = AuditDetailsCodec.Deserialize(row.DetailsJson);
        Assert.Equal("connection refused", roundTripped.ErrorMessage);
    }

    // ── helpers ──────────────────────────────────────────────────────────────

    private async Task InsertCanonicalRowAsync(
        Guid eventId,
        DateTime occurredAt,
        string? actor,
        string action,
        string outcome,
        string category,
        string? target,
        string? sourceNode,
        Guid? correlationId,
        string detailsJson)
    {
        await using var conn = _fixture.OpenConnection();
        await using var cmd = conn.CreateCommand();
        cmd.CommandText = @"
            INSERT INTO dbo.AuditLog
                (EventId, OccurredAtUtc, Actor, Action, Outcome, Category,
                 Target, SourceNode, CorrelationId, DetailsJson)
            VALUES
                (@EventId, @OccurredAtUtc, @Actor, @Action, @Outcome, @Category,
                 @Target, @SourceNode, @CorrelationId, @DetailsJson);";

        cmd.Parameters.AddWithValue("@EventId", eventId);
        cmd.Parameters.AddWithValue("@OccurredAtUtc", occurredAt);
        cmd.Parameters.AddWithValue("@Actor", (object?)actor ?? DBNull.Value);
        cmd.Parameters.AddWithValue("@Action", action);
        cmd.Parameters.AddWithValue("@Outcome", outcome);
        cmd.Parameters.AddWithValue("@Category", category);
        cmd.Parameters.AddWithValue("@Target", (object?)target ?? DBNull.Value);
        cmd.Parameters.AddWithValue("@SourceNode", (object?)sourceNode ?? DBNull.Value);
        cmd.Parameters.AddWithValue("@CorrelationId", (object?)correlationId ?? DBNull.Value);
        cmd.Parameters.AddWithValue("@DetailsJson", detailsJson);
        await cmd.ExecuteNonQueryAsync();
    }

    private async Task<CanonicalRow?> ReadCanonicalRowAsync(Guid eventId, DateTime occurredAt)
    {
        await using var conn = _fixture.OpenConnection();
        await using var cmd = conn.CreateCommand();
        // Read all canonical and computed columns for the inserted row.
        cmd.CommandText = @"
            SELECT
                Action, Category, Outcome, Actor, DetailsJson,
                Kind, Status, SourceSiteId, ExecutionId, ParentExecutionId
            FROM dbo.AuditLog
            WHERE EventId = @EventId AND OccurredAtUtc = @OccurredAtUtc;";
        cmd.Parameters.AddWithValue("@EventId", eventId);
        cmd.Parameters.AddWithValue("@OccurredAtUtc", occurredAt);

        await using var reader = await cmd.ExecuteReaderAsync();
        if (!await reader.ReadAsync())
        {
            return null;
        }

        return new CanonicalRow(
            Action: reader.GetString(0),
            Category: reader.GetString(1),
            Outcome: reader.GetString(2),
            Actor: reader.IsDBNull(3) ? null : reader.GetString(3),
            DetailsJson: reader.IsDBNull(4) ? null : reader.GetString(4),
            Kind: reader.IsDBNull(5) ? null : reader.GetString(5),
            Status: reader.IsDBNull(6) ? null : reader.GetString(6),
            SourceSiteId: reader.IsDBNull(7) ? null : reader.GetString(7),
            ExecutionId: reader.IsDBNull(8) ? null : reader.GetGuid(8),
            ParentExecutionId: reader.IsDBNull(9) ? null : reader.GetGuid(9));
    }

    private sealed record CanonicalRow(
        string Action,
        string Category,
        string Outcome,
        string? Actor,
        string? DetailsJson,
        string? Kind,
        string? Status,
        string? SourceSiteId,
        Guid? ExecutionId,
        Guid? ParentExecutionId);
}
