using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
using IAuditWriter = ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services.IAuditWriter;

namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;

/// <summary>
/// Audit Log #23 — M4 Bundle A (Tasks A1+A2): every synchronous DB call made
/// through <c>Database.Connection("name")</c> emits exactly one
/// <c>DbOutbound</c>/<c>DbWrite</c> audit event with an <c>Extra</c> envelope
/// distinguishing writes (<c>op="write"</c>, <c>rowsAffected=N</c>) from reads
/// (<c>op="read"</c>, <c>rowsReturned=N</c>). The audit emission is
/// best-effort — a thrown <see cref="IAuditWriter.WriteAsync"/> must never
/// abort the script's call, and the original ADO.NET result (or original
/// exception) must surface to the caller unchanged.
/// </summary>
public class DatabaseSyncEmissionTests
{
    /// <summary>
    /// In-memory <see cref="IAuditWriter"/> mirroring the M2 Bundle F stub —
    /// captures every event and may be configured to throw to verify the
    /// 3-layer fail-safe (mirrors <c>CapturingAuditWriter</c> in
    /// <c>ExternalSystemCallAuditEmissionTests</c>).
    /// </summary>
    private sealed class CapturingAuditWriter : IAuditWriter
    {
        // C3 (Task 2.5): store the decomposed row view so assertions keep
        // reading the ScadaBridge domain fields as typed properties.
        public List<AuditRowProjection.AuditRowValues> Events { get; } = new();
        public Exception? ThrowOnWrite { get; set; }

        public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
        {
            if (ThrowOnWrite != null)
            {
                return Task.FromException(ThrowOnWrite);
            }

            Events.Add(evt.AsRow());
            return Task.CompletedTask;
        }
    }

    private const string SiteId = "site-77";
    private const string InstanceName = "Plant.Pump42";
    private const string SourceScript = "ScriptActor:Sync";
    private const string ConnectionName = "machineData";

    /// <summary>
    /// Audit Log #23: a fixed per-execution id used by the default
    /// <see cref="CreateHelper(IDatabaseGateway, IAuditWriter?)"/>
    /// overload so assertions can compare against a known value.
    /// </summary>
    private static readonly Guid TestExecutionId = Guid.NewGuid();

    private static ScriptRuntimeContext.DatabaseHelper CreateHelper(
        IDatabaseGateway gateway,
        IAuditWriter? auditWriter)
        => CreateHelper(gateway, auditWriter, TestExecutionId);

    private static ScriptRuntimeContext.DatabaseHelper CreateHelper(
        IDatabaseGateway gateway,
        IAuditWriter? auditWriter,
        Guid executionId,
        Guid? parentExecutionId = null)
    {
        return new ScriptRuntimeContext.DatabaseHelper(
            gateway,
            InstanceName,
            NullLogger.Instance,
            executionId,
            auditWriter: auditWriter,
            siteId: SiteId,
            sourceScript: SourceScript,
            cachedForwarder: null,
            parentExecutionId: parentExecutionId);
    }

    /// <summary>
    /// Spin up a fresh in-memory SQLite database with a tiny single-table
    /// schema we can write to and read from. The connection is returned in
    /// the open state so the test only has to call <c>Connection()</c> via
    /// the helper. SQLite in-memory databases live as long as the connection
    /// holding them, so the keep-alive root must outlive any auditing
    /// wrapper the test exercises.
    /// </summary>
    private static SqliteConnection NewInMemoryDb(out SqliteConnection keepAlive)
    {
        // The shared-cache name is per-test (Guid) so concurrent tests don't
        // collide. mode=memory keeps it RAM-only; cache=shared lets the
        // keep-alive root and the gateway-returned connection see the same
        // in-memory DB. The keepAlive connection must remain open for the
        // duration of the test or the in-memory DB is discarded.
        var dbName = $"db-{Guid.NewGuid():N}";
        var connStr = $"Data Source={dbName};Mode=Memory;Cache=Shared";

        keepAlive = new SqliteConnection(connStr);
        keepAlive.Open();
        using (var seed = keepAlive.CreateCommand())
        {
            seed.CommandText =
                "CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT NOT NULL);" +
                "INSERT INTO t (id, name) VALUES (1, 'alpha');" +
                "INSERT INTO t (id, name) VALUES (2, 'beta');";
            seed.ExecuteNonQuery();
        }

        var live = new SqliteConnection(connStr);
        live.Open();
        return live;
    }

    [Fact]
    public async Task Execute_InsertSuccess_EmitsOneEvent_KindDbWrite_StatusDelivered_OpWrite_RowsAffected()
    {
        using var keepAlive = new SqliteConnection("Data Source=k;Mode=Memory;Cache=Shared");
        var inner = NewInMemoryDb(out var _);
        var gateway = new Mock<IDatabaseGateway>();
        gateway
            .Setup(g => g.GetConnectionAsync(ConnectionName, It.IsAny<CancellationToken>()))
            .ReturnsAsync(inner);
        var writer = new CapturingAuditWriter();

        var helper = CreateHelper(gateway.Object, writer);
        await using var conn = await helper.Connection(ConnectionName);
        await using var cmd = conn.CreateCommand();
        cmd.CommandText = "INSERT INTO t (id, name) VALUES (3, 'gamma')";
        var rows = await cmd.ExecuteNonQueryAsync();

        Assert.Equal(1, rows);
        var evt = Assert.Single(writer.Events);
        Assert.Equal(AuditChannel.DbOutbound, evt.Channel);
        Assert.Equal(AuditKind.DbWrite, evt.Kind);
        Assert.Equal(AuditStatus.Delivered, evt.Status);
        // C3: ForwardState is no longer a canonical field (site-storage-only).
        Assert.NotNull(evt.Extra);
        Assert.Contains("\"op\":\"write\"", evt.Extra);
        Assert.Contains("\"rowsAffected\":1", evt.Extra);
        Assert.Equal(DateTimeKind.Utc, evt.OccurredAtUtc.Kind);
        Assert.NotEqual(Guid.Empty, evt.EventId);
        Assert.StartsWith(ConnectionName, evt.Target);
    }

    [Fact]
    public async Task ExecuteScalar_Success_EmitsKindDbWrite_OpWrite()
    {
        using var keepAlive = new SqliteConnection("Data Source=k2;Mode=Memory;Cache=Shared");
        var inner = NewInMemoryDb(out var _);
        var gateway = new Mock<IDatabaseGateway>();
        gateway
            .Setup(g => g.GetConnectionAsync(ConnectionName, It.IsAny<CancellationToken>()))
            .ReturnsAsync(inner);
        var writer = new CapturingAuditWriter();

        var helper = CreateHelper(gateway.Object, writer);
        await using var conn = await helper.Connection(ConnectionName);
        await using var cmd = conn.CreateCommand();
        cmd.CommandText = "SELECT COUNT(*) FROM t";
        var scalar = await cmd.ExecuteScalarAsync();

        Assert.NotNull(scalar);
        var evt = Assert.Single(writer.Events);
        Assert.Equal(AuditChannel.DbOutbound, evt.Channel);
        Assert.Equal(AuditKind.DbWrite, evt.Kind);
        Assert.Equal(AuditStatus.Delivered, evt.Status);
        Assert.NotNull(evt.Extra);
        // ExecuteScalar is classified as "write" per the M4 vocabulary lock
        // (Channel=DbOutbound, Kind=DbWrite, Extra.op="write") — the
        // rowsAffected for a SELECT-on-SqlCommand is -1 in ADO.NET; the audit
        // wrapper records whatever DbCommand.ExecuteScalar returned via the
        // built-in path, plus the rowsAffected counter the wrapper observed.
        Assert.Contains("\"op\":\"write\"", evt.Extra);
        Assert.Contains("rowsAffected", evt.Extra);
    }

    [Fact]
    public async Task Execute_Throws_EmitsEvent_StatusFailed_ErrorMessageSet()
    {
        using var keepAlive = new SqliteConnection("Data Source=k3;Mode=Memory;Cache=Shared");
        var inner = NewInMemoryDb(out var _);
        var gateway = new Mock<IDatabaseGateway>();
        gateway
            .Setup(g => g.GetConnectionAsync(ConnectionName, It.IsAny<CancellationToken>()))
            .ReturnsAsync(inner);
        var writer = new CapturingAuditWriter();

        var helper = CreateHelper(gateway.Object, writer);
        await using var conn = await helper.Connection(ConnectionName);
        await using var cmd = conn.CreateCommand();
        // Reference an undefined column — SQLite throws SqliteException synchronously.
        cmd.CommandText = "INSERT INTO t (does_not_exist) VALUES (1)";
        await Assert.ThrowsAsync<SqliteException>(() => cmd.ExecuteNonQueryAsync());

        var evt = Assert.Single(writer.Events);
        Assert.Equal(AuditStatus.Failed, evt.Status);
        Assert.False(string.IsNullOrEmpty(evt.ErrorMessage));
        Assert.NotNull(evt.ErrorDetail);
        Assert.Contains("does_not_exist", evt.ErrorDetail);
    }

    [Fact]
    public async Task ExecuteReader_Success_EmitsKindDbWrite_OpRead_RowsReturned()
    {
        using var keepAlive = new SqliteConnection("Data Source=k4;Mode=Memory;Cache=Shared");
        var inner = NewInMemoryDb(out var _);
        var gateway = new Mock<IDatabaseGateway>();
        gateway
            .Setup(g => g.GetConnectionAsync(ConnectionName, It.IsAny<CancellationToken>()))
            .ReturnsAsync(inner);
        var writer = new CapturingAuditWriter();

        var helper = CreateHelper(gateway.Object, writer);
        await using var conn = await helper.Connection(ConnectionName);
        await using var cmd = conn.CreateCommand();
        cmd.CommandText = "SELECT id, name FROM t ORDER BY id";
        await using var reader = await cmd.ExecuteReaderAsync();
        var rows = 0;
        while (await reader.ReadAsync())
        {
            rows++;
        }
        // Close the reader explicitly so the audit emission (deferred to
        // reader-close per the wrapper contract) fires before assertion.
        await reader.CloseAsync();

        Assert.Equal(2, rows);
        var evt = Assert.Single(writer.Events);
        Assert.Equal(AuditChannel.DbOutbound, evt.Channel);
        Assert.Equal(AuditKind.DbWrite, evt.Kind);
        Assert.Equal(AuditStatus.Delivered, evt.Status);
        Assert.NotNull(evt.Extra);
        Assert.Contains("\"op\":\"read\"", evt.Extra);
        Assert.Contains("\"rowsReturned\":2", evt.Extra);
    }

    [Fact]
    public async Task AuditWriter_Throws_ScriptCall_ReturnsOriginalResult()
    {
        using var keepAlive = new SqliteConnection("Data Source=k5;Mode=Memory;Cache=Shared");
        var inner = NewInMemoryDb(out var _);
        var gateway = new Mock<IDatabaseGateway>();
        gateway
            .Setup(g => g.GetConnectionAsync(ConnectionName, It.IsAny<CancellationToken>()))
            .ReturnsAsync(inner);
        var writer = new CapturingAuditWriter
        {
            ThrowOnWrite = new InvalidOperationException("audit writer down")
        };

        var helper = CreateHelper(gateway.Object, writer);
        await using var conn = await helper.Connection(ConnectionName);
        await using var cmd = conn.CreateCommand();
        cmd.CommandText = "INSERT INTO t (id, name) VALUES (4, 'delta')";
        var rows = await cmd.ExecuteNonQueryAsync();

        // Original ADO.NET result must surface unchanged despite the audit
        // writer faulting — the wrapper swallows + logs the audit failure.
        Assert.Equal(1, rows);
        Assert.Empty(writer.Events);
    }

    [Fact]
    public async Task Provenance_PopulatedFromContext()
    {
        using var keepAlive = new SqliteConnection("Data Source=k6;Mode=Memory;Cache=Shared");
        var inner = NewInMemoryDb(out var _);
        var gateway = new Mock<IDatabaseGateway>();
        gateway
            .Setup(g => g.GetConnectionAsync(ConnectionName, It.IsAny<CancellationToken>()))
            .ReturnsAsync(inner);
        var writer = new CapturingAuditWriter();

        var helper = CreateHelper(gateway.Object, writer);
        await using var conn = await helper.Connection(ConnectionName);
        await using var cmd = conn.CreateCommand();
        cmd.CommandText = "INSERT INTO t (id, name) VALUES (5, 'epsilon')";
        await cmd.ExecuteNonQueryAsync();

        var evt = Assert.Single(writer.Events);
        Assert.Equal(SiteId, evt.SourceSiteId);
        Assert.Equal(InstanceName, evt.SourceInstanceId);
        Assert.Equal(SourceScript, evt.SourceScript);
        // Outbound channel: Actor carries the calling script identity.
        Assert.Equal(SourceScript, evt.Actor);
        // Audit Log #23: the sync DbWrite row carries the per-execution id the
        // helper was constructed with in ExecutionId. CorrelationId is null —
        // a sync one-shot call has no operation lifecycle.
        Assert.Equal(TestExecutionId, evt.ExecutionId);
        Assert.Null(evt.CorrelationId);
        // Audit Log #23 (ParentExecutionId): null for a non-routed run — the
        // default CreateHelper supplies no parentExecutionId.
        Assert.Null(evt.ParentExecutionId);
        Assert.NotEqual(Guid.Empty, evt.EventId);
    }

    [Fact]
    public async Task SyncDbWrite_RoutedRun_StampsParentExecutionId_FromContext()
    {
        // Audit Log #23 (ParentExecutionId, Task 5): an inbound-API-routed run
        // carries the spawning execution's id; the sync DbWrite row must stamp
        // it in ParentExecutionId alongside its own fresh ExecutionId.
        using var keepAlive = new SqliteConnection("Data Source=kp;Mode=Memory;Cache=Shared");
        var inner = NewInMemoryDb(out var _);
        var gateway = new Mock<IDatabaseGateway>();
        gateway
            .Setup(g => g.GetConnectionAsync(ConnectionName, It.IsAny<CancellationToken>()))
            .ReturnsAsync(inner);
        var writer = new CapturingAuditWriter();
        var executionId = Guid.NewGuid();
        var parentExecutionId = Guid.NewGuid();

        var helper = CreateHelper(gateway.Object, writer, executionId, parentExecutionId);
        await using var conn = await helper.Connection(ConnectionName);
        await using var cmd = conn.CreateCommand();
        cmd.CommandText = "INSERT INTO t (id, name) VALUES (9, 'theta')";
        await cmd.ExecuteNonQueryAsync();

        var evt = Assert.Single(writer.Events);
        Assert.Equal(parentExecutionId, evt.ParentExecutionId);
        Assert.Equal(executionId, evt.ExecutionId);
    }

    [Fact]
    public async Task SyncDbWrite_NonRoutedRun_ParentExecutionIdIsNull()
    {
        // A normal (tag/timer) run is not routed — no parent id supplied, so
        // the emitted DbWrite row's ParentExecutionId stays null.
        using var keepAlive = new SqliteConnection("Data Source=kn;Mode=Memory;Cache=Shared");
        var inner = NewInMemoryDb(out var _);
        var gateway = new Mock<IDatabaseGateway>();
        gateway
            .Setup(g => g.GetConnectionAsync(ConnectionName, It.IsAny<CancellationToken>()))
            .ReturnsAsync(inner);
        var writer = new CapturingAuditWriter();

        var helper = CreateHelper(gateway.Object, writer);
        await using var conn = await helper.Connection(ConnectionName);
        await using var cmd = conn.CreateCommand();
        cmd.CommandText = "INSERT INTO t (id, name) VALUES (10, 'iota')";
        await cmd.ExecuteNonQueryAsync();

        var evt = Assert.Single(writer.Events);
        Assert.Null(evt.ParentExecutionId);
    }

    [Fact]
    public async Task SyncDbWrite_StampsExecutionId_AndNullCorrelationId()
    {
        using var keepAlive = new SqliteConnection("Data Source=kc;Mode=Memory;Cache=Shared");
        var inner = NewInMemoryDb(out var _);
        var gateway = new Mock<IDatabaseGateway>();
        gateway
            .Setup(g => g.GetConnectionAsync(ConnectionName, It.IsAny<CancellationToken>()))
            .ReturnsAsync(inner);
        var writer = new CapturingAuditWriter();
        var executionId = Guid.NewGuid();

        var helper = CreateHelper(gateway.Object, writer, executionId);
        await using var conn = await helper.Connection(ConnectionName);
        await using var cmd = conn.CreateCommand();
        cmd.CommandText = "INSERT INTO t (id, name) VALUES (7, 'eta')";
        await cmd.ExecuteNonQueryAsync();

        var evt = Assert.Single(writer.Events);
        Assert.Equal(executionId, evt.ExecutionId);
        // Sync one-shot call: CorrelationId is null (no operation lifecycle).
        Assert.Null(evt.CorrelationId);
    }

    [Fact]
    public async Task DurationMs_NonZero()
    {
        using var keepAlive = new SqliteConnection("Data Source=k7;Mode=Memory;Cache=Shared");
        var inner = NewInMemoryDb(out var _);
        var gateway = new Mock<IDatabaseGateway>();
        gateway
            .Setup(g => g.GetConnectionAsync(ConnectionName, It.IsAny<CancellationToken>()))
            .ReturnsAsync(inner);
        var writer = new CapturingAuditWriter();

        var helper = CreateHelper(gateway.Object, writer);
        await using var conn = await helper.Connection(ConnectionName);
        await using var cmd = conn.CreateCommand();
        cmd.CommandText = "INSERT INTO t (id, name) VALUES (6, 'zeta')";
        await cmd.ExecuteNonQueryAsync();

        var evt = Assert.Single(writer.Events);
        Assert.NotNull(evt.DurationMs);
        Assert.True(evt.DurationMs >= 0, $"DurationMs={evt.DurationMs} should be >= 0");
        Assert.True(evt.DurationMs <= 5000, $"DurationMs={evt.DurationMs} should be <= 5000");
    }
}
