using System.Text;
using NSubstitute;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;

namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Services;

/// <summary>
/// Tests for <see cref="AuditLogExportService"/> (#23 M7-T14 / Bundle F). The
/// service streams the filtered Audit Log query to a destination stream as
/// RFC 4180 CSV. These tests pin:
/// <list type="bullet">
///   <item>Header + body row count for a simple page.</item>
///   <item>RFC 4180 quoting for fields containing commas / quotes / CR-LF.</item>
///   <item>Null fields render as empty (no literal "null").</item>
///   <item>Row cap honoured + cap footer appended.</item>
///   <item>Cancellation tokens propagate mid-stream.</item>
/// </list>
/// </summary>
public class AuditLogExportServiceTests
{
    // C3 (Task 2.5): the export service reads canonical ZB.MOM.WW.Audit.AuditEvent rows
    // from IAuditLogRepository and decomposes each into the flat 21-column CSV shape;
    // build the canonical rows via the shared factory (domain fields ride in DetailsJson).
    private static AuditEvent SimpleEvent(string id, string? target = null, string? error = null)
        => ScadaBridgeAuditEventFactory.Create(
            channel: AuditChannel.ApiOutbound,
            kind: AuditKind.ApiCall,
            status: AuditStatus.Delivered,
            eventId: Guid.Parse(id),
            occurredAtUtc: new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc),
            target: target,
            sourceSiteId: "plant-a",
            httpStatus: 200,
            durationMs: 42,
            errorMessage: error,
            ingestedAtUtc: new DateTimeOffset(new DateTime(2026, 5, 20, 12, 0, 1, DateTimeKind.Utc)));

    [Fact]
    public async Task ExportAsync_FiveRows_WritesHeaderPlusFiveRows()
    {
        var rows = new List<AuditEvent>
        {
            SimpleEvent("11111111-1111-1111-1111-111111111111"),
            SimpleEvent("22222222-2222-2222-2222-222222222222"),
            SimpleEvent("33333333-3333-3333-3333-333333333333"),
            SimpleEvent("44444444-4444-4444-4444-444444444444"),
            SimpleEvent("55555555-5555-5555-5555-555555555555"),
        };
        var repo = Substitute.For<IAuditLogRepository>();
        // First call returns the 5 rows; subsequent calls return empty so the
        // service terminates the keyset loop.
        repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
            .Returns(
                Task.FromResult<IReadOnlyList<AuditEvent>>(rows),
                Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));

        var sut = new AuditLogExportService(repo);

        using var ms = new MemoryStream();
        await sut.ExportAsync(new AuditLogQueryFilter(), maxRows: 100, ms, CancellationToken.None);

        var csv = Encoding.UTF8.GetString(ms.ToArray());
        var lines = csv.Split("\r\n", StringSplitOptions.None);

        // 1 header + 5 rows + trailing empty token from final \r\n = 7 entries.
        Assert.Equal(7, lines.Length);
        Assert.StartsWith("EventId,OccurredAtUtc,IngestedAtUtc,Channel,Kind,CorrelationId,SourceSiteId,", lines[0]);
        Assert.StartsWith("11111111-1111-1111-1111-111111111111,", lines[1]);
        Assert.StartsWith("55555555-5555-5555-5555-555555555555,", lines[5]);
        Assert.Equal(string.Empty, lines[6]);
    }

    [Fact]
    public async Task ExportAsync_HeaderHasAll21Columns_InSpecOrder()
    {
        var repo = Substitute.For<IAuditLogRepository>();
        repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
            .Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));

        var sut = new AuditLogExportService(repo);
        using var ms = new MemoryStream();
        await sut.ExportAsync(new AuditLogQueryFilter(), maxRows: 10, ms, CancellationToken.None);

        var csv = Encoding.UTF8.GetString(ms.ToArray()).TrimEnd('\r', '\n');
        var header = csv.Split("\r\n")[0];
        var columns = header.Split(',');

        Assert.Equal(21, columns.Length);
        Assert.Equal(new[]
        {
            "EventId", "OccurredAtUtc", "IngestedAtUtc", "Channel", "Kind",
            "CorrelationId", "SourceSiteId", "SourceInstanceId", "SourceScript",
            "Actor", "Target", "Status", "HttpStatus", "DurationMs",
            "ErrorMessage", "ErrorDetail", "RequestSummary", "ResponseSummary",
            "PayloadTruncated", "Extra", "ForwardState",
        }, columns);
    }

    [Fact]
    public async Task ExportAsync_FieldWithComma_QuotedAndEscaped()
    {
        // Target contains a comma → field must be wrapped in double quotes.
        // Target with embedded quote → quote must be doubled ("") and field quoted.
        // ResponseSummary contains CR-LF → field must be quoted.
        var row = ScadaBridgeAuditEventFactory.Create(
            channel: AuditChannel.ApiOutbound,
            kind: AuditKind.ApiCall,
            status: AuditStatus.Delivered,
            eventId: Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"),
            occurredAtUtc: new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc),
            target: "x",
            sourceSiteId: "plant-a, secondary",     // comma
            sourceScript: "say \"hi\"",             // embedded quote
            errorMessage: "boom\r\nthen again");    // CR-LF
        var repo = Substitute.For<IAuditLogRepository>();
        repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
            .Returns(
                Task.FromResult<IReadOnlyList<AuditEvent>>(new[] { row }),
                Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));

        var sut = new AuditLogExportService(repo);
        using var ms = new MemoryStream();
        await sut.ExportAsync(new AuditLogQueryFilter(), maxRows: 10, ms, CancellationToken.None);

        var csv = Encoding.UTF8.GetString(ms.ToArray());

        // Comma-bearing field is quoted.
        Assert.Contains("\"plant-a, secondary\"", csv);
        // Embedded quote is doubled inside a quoted field.
        Assert.Contains("\"say \"\"hi\"\"\"", csv);
        // Newline-bearing field is quoted; the inner \r\n stays as-is.
        Assert.Contains("\"boom\r\nthen again\"", csv);
    }

    [Fact]
    public async Task ExportAsync_NullField_WrittenAsEmpty()
    {
        // Build a row with deliberate nulls for every nullable column.
        var row = ScadaBridgeAuditEventFactory.Create(
            channel: AuditChannel.ApiOutbound,
            kind: AuditKind.ApiCall,
            status: AuditStatus.Submitted,
            eventId: Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"),
            occurredAtUtc: new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc));
        var repo = Substitute.For<IAuditLogRepository>();
        repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
            .Returns(
                Task.FromResult<IReadOnlyList<AuditEvent>>(new[] { row }),
                Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));

        var sut = new AuditLogExportService(repo);
        using var ms = new MemoryStream();
        await sut.ExportAsync(new AuditLogQueryFilter(), maxRows: 10, ms, CancellationToken.None);

        var csv = Encoding.UTF8.GetString(ms.ToArray());
        var dataLine = csv.Split("\r\n")[1];
        var fields = dataLine.Split(',');

        // EventId(0), OccurredAtUtc(1), IngestedAtUtc(2), Channel(3), Kind(4),
        // CorrelationId(5), SourceSiteId(6), SourceInstanceId(7), SourceScript(8),
        // Actor(9), Target(10), Status(11), HttpStatus(12), DurationMs(13),
        // ErrorMessage(14), ErrorDetail(15), RequestSummary(16), ResponseSummary(17),
        // PayloadTruncated(18), Extra(19), ForwardState(20)
        Assert.Equal(21, fields.Length);
        Assert.Equal("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", fields[0]);
        Assert.Equal(string.Empty, fields[2]);  // IngestedAtUtc null
        Assert.Equal(string.Empty, fields[5]);  // CorrelationId null
        Assert.Equal(string.Empty, fields[6]);  // SourceSiteId null
        Assert.Equal(string.Empty, fields[12]); // HttpStatus null
        Assert.Equal(string.Empty, fields[14]); // ErrorMessage null
        Assert.Equal("False", fields[18]);      // PayloadTruncated
        Assert.Equal(string.Empty, fields[20]); // ForwardState null
    }

    [Fact]
    public async Task ExportAsync_RowCountAboveCap_Truncates_AppendsCapFooter()
    {
        // The service is asked for 3 rows but the repo would happily yield 5.
        // Output must contain exactly 3 data rows + a footer "# Capped at 3 rows..."
        var rows = Enumerable.Range(0, 5)
            .Select(i => SimpleEvent(Guid.NewGuid().ToString()))
            .ToList();
        var repo = Substitute.For<IAuditLogRepository>();
        // Repo returns the 5 rows in a single page; the service must stop after 3.
        repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
            .Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(rows));

        var sut = new AuditLogExportService(repo);
        using var ms = new MemoryStream();
        await sut.ExportAsync(new AuditLogQueryFilter(), maxRows: 3, ms, CancellationToken.None);

        var csv = Encoding.UTF8.GetString(ms.ToArray());
        var lines = csv.Split("\r\n", StringSplitOptions.None);

        // 1 header + 3 rows + 1 footer + trailing empty = 6 entries.
        Assert.Equal(6, lines.Length);
        Assert.Equal("# Capped at 3 rows. Use the CLI for larger exports.", lines[4]);
    }

    [Fact]
    public async Task ExportAsync_CancellationToken_StopsMidStream()
    {
        // Repo yields a single page, then on the next page call we observe the
        // canceled token and throw — service should propagate OperationCanceled.
        var cts = new CancellationTokenSource();
        var firstPage = new List<AuditEvent>
        {
            SimpleEvent("11111111-1111-1111-1111-111111111111"),
            SimpleEvent("22222222-2222-2222-2222-222222222222"),
        };

        var repo = Substitute.For<IAuditLogRepository>();
        repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
            .Returns(callInfo =>
            {
                // Cancel after delivering the first page so the next loop iteration
                // sees a canceled token.
                cts.Cancel();
                return Task.FromResult<IReadOnlyList<AuditEvent>>(firstPage);
            });

        var sut = new AuditLogExportService(repo);
        using var ms = new MemoryStream();

        // The service writes the first page then checks the token before pulling
        // the next — we expect OperationCanceledException to surface.
        await Assert.ThrowsAnyAsync<OperationCanceledException>(async () =>
            await sut.ExportAsync(new AuditLogQueryFilter(), maxRows: 1000, ms, cts.Token));
    }

    [Fact]
    public async Task ExportAsync_PaginatesUsingLastRowAsCursor()
    {
        // Two pages of 2 rows each, then empty. The service must pass the last
        // row of page 1 as the cursor on the page-2 call.
        AuditEvent Row(string id, DateTime occurredAt) => ScadaBridgeAuditEventFactory.Create(
            channel: AuditChannel.ApiOutbound,
            kind: AuditKind.ApiCall,
            status: AuditStatus.Delivered,
            eventId: Guid.Parse(id),
            occurredAtUtc: occurredAt);
        var p1 = new List<AuditEvent>
        {
            Row("11111111-1111-1111-1111-111111111111", new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc)),
            Row("22222222-2222-2222-2222-222222222222", new DateTime(2026, 5, 20, 11, 59, 0, DateTimeKind.Utc)),
        };
        var p2 = new List<AuditEvent>
        {
            Row("33333333-3333-3333-3333-333333333333", new DateTime(2026, 5, 20, 11, 58, 0, DateTimeKind.Utc)),
        };

        var pagings = new List<AuditLogPaging>();
        var repo = Substitute.For<IAuditLogRepository>();
        repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Do<AuditLogPaging>(p => pagings.Add(p)), Arg.Any<CancellationToken>())
            .Returns(
                Task.FromResult<IReadOnlyList<AuditEvent>>(p1),
                Task.FromResult<IReadOnlyList<AuditEvent>>(p2),
                Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));

        var sut = new AuditLogExportService(repo);
        using var ms = new MemoryStream();
        // PageSize is 2 so the first page returns full and the loop continues.
        await sut.ExportAsync(new AuditLogQueryFilter(), maxRows: 10, ms, CancellationToken.None, pageSize: 2);

        Assert.True(pagings.Count >= 2, $"Expected at least 2 paged calls, got {pagings.Count}");
        Assert.Null(pagings[0].AfterEventId);
        Assert.Null(pagings[0].AfterOccurredAtUtc);
        Assert.Equal(p1[^1].EventId, pagings[1].AfterEventId);
        // Canonical OccurredAtUtc is a DateTimeOffset; the paging cursor is a DateTime —
        // compare via the decomposed row view (Kind=Utc DateTime).
        Assert.Equal(p1[^1].AsRow().OccurredAtUtc, pagings[1].AfterOccurredAtUtc);
    }
}
