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;
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 — M2 Bundle F (Task F1): every script-initiated
/// <c>ExternalSystem.Call</c> emits exactly one <c>ApiOutbound</c>/<c>ApiCall</c>
/// audit event via the wrapper inside
/// <see cref="ScriptRuntimeContext.ExternalSystemHelper"/>. The audit emission
/// is best-effort: a thrown <see cref="IAuditWriter.WriteAsync"/> must never
/// abort the script's call, and the original <see cref="ExternalCallResult"/>
/// (or original exception) must surface to the caller unchanged.
/// </summary>
public class ExternalSystemCallAuditEmissionTests
{
    /// <summary>
    /// In-memory <see cref="IAuditWriter"/> that records every event passed to
    /// <see cref="WriteAsync"/>. Optionally configurable to throw, simulating a
    /// catastrophic audit-writer failure that the wrapper must swallow.
    /// </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:CheckPressure";

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

    private static ScriptRuntimeContext.ExternalSystemHelper CreateHelper(
        IExternalSystemClient client,
        IAuditWriter? auditWriter)
        => CreateHelper(client, auditWriter, TestExecutionId);

    private static ScriptRuntimeContext.ExternalSystemHelper CreateHelper(
        IExternalSystemClient client,
        IAuditWriter? auditWriter,
        Guid executionId,
        Guid? parentExecutionId = null)
    {
        return new ScriptRuntimeContext.ExternalSystemHelper(
            client,
            InstanceName,
            NullLogger.Instance,
            executionId,
            auditWriter,
            SiteId,
            SourceScript,
            cachedForwarder: null,
            parentExecutionId: parentExecutionId);
    }

    [Fact]
    public async Task Call_Success_EmitsOneEvent_Channel_ApiOutbound_Kind_ApiCall_Status_Delivered()
    {
        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 helper = CreateHelper(client.Object, writer);
        var result = await helper.Call("ERP", "GetOrder");

        Assert.True(result.Success);
        Assert.Single(writer.Events);
        var evt = writer.Events[0];
        Assert.Equal(AuditChannel.ApiOutbound, evt.Channel);
        Assert.Equal(AuditKind.ApiCall, evt.Kind);
        Assert.Equal(AuditStatus.Delivered, evt.Status);
        Assert.Equal("ERP.GetOrder", evt.Target);
        // C3 (Task 2.5): ForwardState is no longer a field on the canonical
        // record — it is a site-storage-only concern. The emitter no longer
        // sets it; the site SQLite writer defaults it to Pending on insert.
        Assert.Equal(DateTimeKind.Utc, evt.OccurredAtUtc.Kind);
        Assert.NotEqual(Guid.Empty, evt.EventId);
        Assert.False(evt.PayloadTruncated);
        // No call arguments → null request summary; the response body is captured.
        Assert.Null(evt.RequestSummary);
        Assert.Equal("{}", evt.ResponseSummary);
    }

    [Fact]
    public async Task Call_CapturesRequestArgs_AndResponseBody_OnTheAuditRow()
    {
        var client = new Mock<IExternalSystemClient>();
        client
            .Setup(c => c.CallAsync("Weather", "GetCurrent", It.IsAny<IReadOnlyDictionary<string, object?>?>(), It.IsAny<CancellationToken>()))
            .ReturnsAsync(new ExternalCallResult(true, "{\"tempC\":11.4}", null));
        var writer = new CapturingAuditWriter();

        var helper = CreateHelper(client.Object, writer);
        var args = new Dictionary<string, object?> { ["city"] = "Dublin" };
        await helper.Call("Weather", "GetCurrent", args);

        var evt = Assert.Single(writer.Events);
        // RequestSummary is the serialized argument dictionary; ResponseSummary
        // is the verbatim response body. (Cap + redaction are the writer's job.)
        Assert.Equal("{\"city\":\"Dublin\"}", evt.RequestSummary);
        Assert.Equal("{\"tempC\":11.4}", evt.ResponseSummary);
    }

    [Fact]
    public async Task Call_HTTP500_EmitsEvent_Status_Failed_HttpStatus_500_ErrorMessage_Set()
    {
        var client = new Mock<IExternalSystemClient>();
        client
            .Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny<IReadOnlyDictionary<string, object?>?>(), It.IsAny<CancellationToken>()))
            .ReturnsAsync(new ExternalCallResult(false, null, "Transient error: HTTP 500 from ERP: Internal Server Error"));
        var writer = new CapturingAuditWriter();

        var helper = CreateHelper(client.Object, writer);
        var result = await helper.Call("ERP", "GetOrder");

        Assert.False(result.Success);
        Assert.Single(writer.Events);
        var evt = writer.Events[0];
        Assert.Equal(AuditStatus.Failed, evt.Status);
        Assert.Equal(500, evt.HttpStatus);
        Assert.False(string.IsNullOrEmpty(evt.ErrorMessage));
        Assert.Contains("500", evt.ErrorMessage);
    }

    [Fact]
    public async Task Call_HTTP400_EmitsEvent_Status_Failed_HttpStatus_400()
    {
        var client = new Mock<IExternalSystemClient>();
        client
            .Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny<IReadOnlyDictionary<string, object?>?>(), It.IsAny<CancellationToken>()))
            .ReturnsAsync(new ExternalCallResult(false, null, "Permanent error: HTTP 400 from ERP: Bad Request"));
        var writer = new CapturingAuditWriter();

        var helper = CreateHelper(client.Object, writer);
        var result = await helper.Call("ERP", "GetOrder");

        Assert.False(result.Success);
        Assert.Single(writer.Events);
        var evt = writer.Events[0];
        Assert.Equal(AuditStatus.Failed, evt.Status);
        Assert.Equal(400, evt.HttpStatus);
    }

    [Fact]
    public async Task Call_ClientThrows_NetworkException_EmitsEvent_Status_Failed_ErrorMessage_FromException()
    {
        var client = new Mock<IExternalSystemClient>();
        var networkEx = new HttpRequestException("network down");
        client
            .Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny<IReadOnlyDictionary<string, object?>?>(), It.IsAny<CancellationToken>()))
            .ThrowsAsync(networkEx);
        var writer = new CapturingAuditWriter();

        var helper = CreateHelper(client.Object, writer);
        var thrown = await Assert.ThrowsAsync<HttpRequestException>(() => helper.Call("ERP", "GetOrder"));
        Assert.Same(networkEx, thrown);

        Assert.Single(writer.Events);
        var evt = writer.Events[0];
        Assert.Equal(AuditStatus.Failed, evt.Status);
        Assert.Null(evt.HttpStatus);
        Assert.Equal("network down", evt.ErrorMessage);
        Assert.NotNull(evt.ErrorDetail);
        Assert.Contains("HttpRequestException", evt.ErrorDetail);
    }

    [Fact]
    public async Task AuditWriter_Throws_Script_Call_Returns_Original_Result_Unchanged()
    {
        var client = new Mock<IExternalSystemClient>();
        var expected = new ExternalCallResult(true, "{\"v\":1}", null);
        client
            .Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny<IReadOnlyDictionary<string, object?>?>(), It.IsAny<CancellationToken>()))
            .ReturnsAsync(expected);
        var writer = new CapturingAuditWriter
        {
            ThrowOnWrite = new InvalidOperationException("audit writer down")
        };

        var helper = CreateHelper(client.Object, writer);
        var result = await helper.Call("ERP", "GetOrder");

        Assert.Same(expected, result);
        Assert.Empty(writer.Events);
    }

    [Fact]
    public async Task Provenance_Populated_FromContext()
    {
        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, null));
        var writer = new CapturingAuditWriter();

        var helper = CreateHelper(client.Object, writer);
        var beforeId = Guid.NewGuid();

        await helper.Call("ERP", "GetOrder");

        var evt = writer.Events[0];
        Assert.NotEqual(beforeId, evt.EventId);
        Assert.NotEqual(Guid.Empty, evt.EventId);
        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 ApiCall 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);
    }

    [Fact]
    public async Task Call_RoutedRun_StampsParentExecutionId_FromContext()
    {
        // Audit Log #23 (ParentExecutionId, Task 5): an inbound-API-routed run
        // carries the spawning execution's id; the sync ApiCall row must stamp
        // it in ParentExecutionId alongside 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 parentExecutionId = Guid.NewGuid();

        var helper = CreateHelper(client.Object, writer, TestExecutionId, parentExecutionId);
        await helper.Call("ERP", "GetOrder");

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

    [Fact]
    public async Task Call_NonRoutedRun_ParentExecutionIdIsNull()
    {
        // A normal (tag/timer) run is not routed — no parent id supplied, so
        // the emitted ApiCall row's ParentExecutionId stays null.
        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 helper = CreateHelper(client.Object, writer);
        await helper.Call("ERP", "GetOrder");

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

    [Fact]
    public async Task Call_SyncApiCall_StampsExecutionId_AndNullCorrelationId()
    {
        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 executionId = Guid.NewGuid();

        var helper = CreateHelper(client.Object, writer, executionId);
        await helper.Call("ERP", "GetOrder");

        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 Call_TwoCallsOnSameHelper_ShareTheSameExecutionId()
    {
        var client = new Mock<IExternalSystemClient>();
        client
            .Setup(c => c.CallAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<IReadOnlyDictionary<string, object?>?>(), It.IsAny<CancellationToken>()))
            .ReturnsAsync(new ExternalCallResult(true, "{}", null));
        var writer = new CapturingAuditWriter();
        var executionId = Guid.NewGuid();

        var helper = CreateHelper(client.Object, writer, executionId);
        await helper.Call("ERP", "GetOrder");
        await helper.Call("ERP", "GetCustomer");

        Assert.Equal(2, writer.Events.Count);
        // Both sync ApiCall rows from one execution carry the same ExecutionId.
        Assert.Equal(executionId, writer.Events[0].ExecutionId);
        Assert.Equal(executionId, writer.Events[1].ExecutionId);
        Assert.Equal(writer.Events[0].ExecutionId, writer.Events[1].ExecutionId);
        // Neither sync call carries a CorrelationId.
        Assert.Null(writer.Events[0].CorrelationId);
        Assert.Null(writer.Events[1].CorrelationId);
    }

    [Fact]
    public async Task DurationMs_Recorded_NonZero()
    {
        var client = new Mock<IExternalSystemClient>();
        client
            .Setup(c => c.CallAsync("ERP", "Slow", It.IsAny<IReadOnlyDictionary<string, object?>?>(), It.IsAny<CancellationToken>()))
            .Returns(async () =>
            {
                await Task.Delay(20);
                return new ExternalCallResult(true, null, null);
            });
        var writer = new CapturingAuditWriter();

        var helper = CreateHelper(client.Object, writer);
        await helper.Call("ERP", "Slow");

        var evt = writer.Events[0];
        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");
    }
}
