using Akka.Actor;
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 — execution-correlation tests exercised through a full
/// <see cref="ScriptRuntimeContext"/>:
///
/// <list type="bullet">
/// <item><description>
/// The <c>?? Guid.NewGuid()</c> fallback in the <see cref="ScriptRuntimeContext"/>
/// ctor: when no execution id is supplied (tag-change / timer-triggered
/// executions) a fresh, non-empty id is minted and stamped on the emitted rows.
/// </description></item>
/// <item><description>
/// The execution-wide contract: an <c>ExternalSystem.Call</c> and a sync
/// <c>Database</c> write performed through ONE context share a single
/// <see cref="AuditEvent.ExecutionId"/>. The per-operation
/// <see cref="AuditEvent.CorrelationId"/> stays null for these sync one-shot
/// calls — a sync call has no operation lifecycle.
/// </description></item>
/// </list>
/// </summary>
public class ExecutionCorrelationContextTests
{
    /// <summary>
    /// In-memory <see cref="IAuditWriter"/> capturing every emitted event
    /// (mirrors the <c>CapturingAuditWriter</c> stubs in
    /// <see cref="ExternalSystemCallAuditEmissionTests"/> /
    /// <see cref="DatabaseSyncEmissionTests"/>).
    /// </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 Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
        {
            Events.Add(evt.AsRow());
            return Task.CompletedTask;
        }
    }

    private const string InstanceName = "Plant.Pump42";
    private const string ConnectionName = "machineData";

    /// <summary>
    /// Builds a full <see cref="ScriptRuntimeContext"/> wired with the external
    /// system client, database gateway and audit writer the cross-helper test
    /// needs. The actor refs are <see cref="ActorRefs.Nobody"/> — the
    /// integration helpers (ExternalSystem / Database) never touch them — and
    /// <paramref name="executionId"/> defaults to null so the ctor's
    /// <c>?? Guid.NewGuid()</c> fallback is exercised unless a test supplies one.
    /// </summary>
    private static ScriptRuntimeContext CreateContext(
        IExternalSystemClient? externalSystemClient,
        IDatabaseGateway? databaseGateway,
        IAuditWriter? auditWriter,
        Guid? executionId = null,
        Guid? parentExecutionId = null)
    {
        var compilationService = new ScriptCompilationService(
            NullLogger<ScriptCompilationService>.Instance);
        var sharedScriptLibrary = new SharedScriptLibrary(
            compilationService, NullLogger<SharedScriptLibrary>.Instance);

        return new ScriptRuntimeContext(
            ActorRefs.Nobody,
            sharedScriptLibrary,
            currentCallDepth: 0,
            maxCallDepth: 10,
            askTimeout: TimeSpan.FromSeconds(5),
            instanceName: InstanceName,
            logger: NullLogger.Instance,
            externalSystemClient: externalSystemClient,
            databaseGateway: databaseGateway,
            storeAndForward: null,
            siteCommunicationActor: null,
            siteId: "site-77",
            sourceScript: "ScriptActor:OnTick",
            auditWriter: auditWriter,
            operationTrackingStore: null,
            cachedForwarder: null,
            executionId: executionId,
            parentExecutionId: parentExecutionId);
    }

    /// <summary>
    /// Spin up a fresh in-memory SQLite database with a tiny single-table
    /// schema. The keep-alive root must outlive any auditing wrapper the test
    /// exercises (mirrors <c>DatabaseSyncEmissionTests.NewInMemoryDb</c>).
    /// </summary>
    private static SqliteConnection NewInMemoryDb(out SqliteConnection keepAlive)
    {
        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);";
            seed.ExecuteNonQuery();
        }

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

    [Fact]
    public async Task NoExecutionIdSupplied_SyncCall_StampsFreshNonEmptyExecutionId()
    {
        // No executionId argument — the ScriptRuntimeContext ctor's
        // `?? Guid.NewGuid()` fallback must mint one (this is the unsupplied-id
        // branch every other audit test bypasses by passing an explicit id).
        var client = new Mock<IExternalSystemClient>();
        client
            .Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny<IReadOnlyDictionary<string, object?>?>(), It.IsAny<CancellationToken>()))
            .ReturnsAsync(new ExternalCallResult(true, "{}", null));
        var writer = new CapturingAuditWriter();

        var context = CreateContext(client.Object, databaseGateway: null, writer);
        await context.ExternalSystem.Call("ERP", "GetOrder");

        var evt = Assert.Single(writer.Events);
        Assert.NotNull(evt.ExecutionId);
        Assert.NotEqual(Guid.Empty, evt.ExecutionId!.Value);
        // A sync one-shot call has no operation lifecycle — CorrelationId is null.
        Assert.Null(evt.CorrelationId);
    }

    [Fact]
    public async Task SameContext_ApiCallAndDbWrite_ShareTheSameExecutionId()
    {
        // The execution-wide contract: an ExternalSystem.Call AND a sync
        // Database write performed through ONE ScriptRuntimeContext must both
        // carry the same ExecutionId, so an audit reader can tie every
        // trust-boundary action from one script run together.
        using var keepAlive = new SqliteConnection("Data Source=ecc;Mode=Memory;Cache=Shared");
        var innerDb = NewInMemoryDb(out var _);

        var client = new Mock<IExternalSystemClient>();
        client
            .Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny<IReadOnlyDictionary<string, object?>?>(), It.IsAny<CancellationToken>()))
            .ReturnsAsync(new ExternalCallResult(true, "{}", null));

        var gateway = new Mock<IDatabaseGateway>();
        gateway
            .Setup(g => g.GetConnectionAsync(ConnectionName, It.IsAny<CancellationToken>()))
            .ReturnsAsync(innerDb);

        var writer = new CapturingAuditWriter();
        var context = CreateContext(client.Object, gateway.Object, writer);

        // 1) outbound API call through the context's ExternalSystem helper.
        await context.ExternalSystem.Call("ERP", "GetOrder");

        // 2) sync DB write through the SAME context's Database helper.
        await using (var conn = await context.Database.Connection(ConnectionName))
        await using (var cmd = conn.CreateCommand())
        {
            cmd.CommandText = "INSERT INTO t (id, name) VALUES (1, 'alpha')";
            await cmd.ExecuteNonQueryAsync();
        }

        Assert.Equal(2, writer.Events.Count);
        var apiRow = Assert.Single(writer.Events, e => e.Channel == AuditChannel.ApiOutbound);
        var dbRow = Assert.Single(writer.Events, e => e.Channel == AuditChannel.DbOutbound);

        Assert.NotNull(apiRow.ExecutionId);
        Assert.NotEqual(Guid.Empty, apiRow.ExecutionId!.Value);
        // The ApiCall row and the DbWrite row, emitted by two different helpers
        // resolved off one context, carry the identical ExecutionId.
        Assert.Equal(apiRow.ExecutionId, dbRow.ExecutionId);
        // Both are sync one-shot calls — neither carries a CorrelationId.
        Assert.Null(apiRow.CorrelationId);
        Assert.Null(dbRow.CorrelationId);
    }

    [Fact]
    public async Task ParentExecutionIdSupplied_StampedOnEmittedRow_AndDistinctFromOwnExecutionId()
    {
        // Audit Log #23 (ParentExecutionId, Task 5): an inbound-API-routed call
        // supplies the spawning execution's ExecutionId as the routed script's
        // ParentExecutionId. Every audit row the routed script emits must carry
        // that value in AuditEvent.ParentExecutionId — and still carry its OWN
        // fresh ExecutionId, distinct from the parent (the routed script is a
        // new execution, it does not inherit the parent's id).
        var parentExecutionId = Guid.NewGuid();

        var client = new Mock<IExternalSystemClient>();
        client
            .Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny<IReadOnlyDictionary<string, object?>?>(), It.IsAny<CancellationToken>()))
            .ReturnsAsync(new ExternalCallResult(true, "{}", null));
        var writer = new CapturingAuditWriter();

        var context = CreateContext(
            client.Object,
            databaseGateway: null,
            writer,
            // executionId omitted — the ctor's `?? Guid.NewGuid()` fallback runs.
            parentExecutionId: parentExecutionId);
        await context.ExternalSystem.Call("ERP", "GetOrder");

        var evt = Assert.Single(writer.Events);
        // The parent id is stamped on the emitted row untouched.
        Assert.Equal(parentExecutionId, evt.ParentExecutionId);
        // The routed script's own ExecutionId is freshly generated, non-empty,
        // and NOT the parent id — they are separate correlation values.
        Assert.NotNull(evt.ExecutionId);
        Assert.NotEqual(Guid.Empty, evt.ExecutionId!.Value);
        Assert.NotEqual(parentExecutionId, evt.ExecutionId!.Value);
    }

    [Fact]
    public async Task NoParentExecutionIdSupplied_NonRoutedRun_ParentStaysNullOnEmittedRow()
    {
        // A normal (tag-change / timer) script run is not inbound-API-routed —
        // no ParentExecutionId is supplied, so every emitted audit row carries
        // a null ParentExecutionId while the run still gets its own fresh
        // ExecutionId.
        var client = new Mock<IExternalSystemClient>();
        client
            .Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny<IReadOnlyDictionary<string, object?>?>(), It.IsAny<CancellationToken>()))
            .ReturnsAsync(new ExternalCallResult(true, "{}", null));
        var writer = new CapturingAuditWriter();

        var context = CreateContext(client.Object, databaseGateway: null, writer);
        await context.ExternalSystem.Call("ERP", "GetOrder");

        var evt = Assert.Single(writer.Events);
        Assert.Null(evt.ParentExecutionId);
        Assert.NotNull(evt.ExecutionId);
        Assert.NotEqual(Guid.Empty, evt.ExecutionId!.Value);
    }

    [Fact]
    public async Task ParentExecutionIdSupplied_StampedOnApiAndDbRows_FromSameContext()
    {
        // The execution-wide contract extends to ParentExecutionId: an
        // ExternalSystem.Call and a sync Database write performed through ONE
        // routed context both carry the identical ParentExecutionId.
        var parentExecutionId = Guid.NewGuid();
        using var keepAlive = new SqliteConnection("Data Source=ecc-parent;Mode=Memory;Cache=Shared");
        var innerDb = NewInMemoryDb(out var _);

        var client = new Mock<IExternalSystemClient>();
        client
            .Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny<IReadOnlyDictionary<string, object?>?>(), It.IsAny<CancellationToken>()))
            .ReturnsAsync(new ExternalCallResult(true, "{}", null));

        var gateway = new Mock<IDatabaseGateway>();
        gateway
            .Setup(g => g.GetConnectionAsync(ConnectionName, It.IsAny<CancellationToken>()))
            .ReturnsAsync(innerDb);

        var writer = new CapturingAuditWriter();
        var context = CreateContext(
            client.Object, gateway.Object, writer, parentExecutionId: parentExecutionId);

        await context.ExternalSystem.Call("ERP", "GetOrder");

        await using (var conn = await context.Database.Connection(ConnectionName))
        await using (var cmd = conn.CreateCommand())
        {
            cmd.CommandText = "INSERT INTO t (id, name) VALUES (1, 'alpha')";
            await cmd.ExecuteNonQueryAsync();
        }

        Assert.Equal(2, writer.Events.Count);
        var apiRow = Assert.Single(writer.Events, e => e.Channel == AuditChannel.ApiOutbound);
        var dbRow = Assert.Single(writer.Events, e => e.Channel == AuditChannel.DbOutbound);
        Assert.Equal(parentExecutionId, apiRow.ParentExecutionId);
        Assert.Equal(parentExecutionId, dbRow.ParentExecutionId);
    }
}
