using System.Net;
using System.Text;
using System.Web;
using ZB.MOM.WW.ScadaBridge.CLI;
using ZB.MOM.WW.ScadaBridge.CLI.Commands;

namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;

/// <summary>
/// Tests for the <c>scadabridge audit export</c> subcommand (Audit Log #23 M8-T3):
/// required-flag enforcement, query-string construction, streaming the response body
/// to the output file, and the parquet-not-implemented (501) path.
/// </summary>
[Collection("Console")]
public class AuditExportCommandTests
{
    // ---- CLI parsing: required flags --------------------------------------

    [Fact]
    public void Export_MissingRequiredFlag_ProducesParseErrorAndNonZeroExit()
    {
        var root = AuditCommandTestHarness.BuildRoot();
        // --output is omitted.
        var (exit, _, err) = AuditCommandTestHarness.Invoke(
            root, "audit", "export", "--since", "1h", "--until", "0h", "--format", "csv");
        Assert.NotEqual(0, exit);
        Assert.Contains("--output", err);
    }

    [Fact]
    public void Export_AllRequiredFlagsPresent_ParsesWithoutError()
    {
        var root = AuditCommandTestHarness.BuildRoot();
        var parse = root.Parse(new[]
        {
            "audit", "export", "--since", "1h", "--until", "0h",
            "--format", "csv", "--output", "/tmp/out.csv",
        });
        Assert.Empty(parse.Errors);
    }

    [Fact]
    public void Export_InvalidFormat_Rejected()
    {
        var root = AuditCommandTestHarness.BuildRoot();
        var parse = root.Parse(new[]
        {
            "audit", "export", "--since", "1h", "--until", "0h",
            "--format", "xml", "--output", "/tmp/out.xml",
        });
        Assert.NotEmpty(parse.Errors);
    }

    // ---- Query string -----------------------------------------------------

    [Fact]
    public void BuildQueryString_IncludesWindowFormatAndFilters()
    {
        var now = DateTimeOffset.Parse("2026-05-20T12:00:00Z");
        var args = new AuditExportArgs
        {
            Since = "1h",
            Until = "2026-05-20T12:00:00Z",
            Format = "jsonl",
            Output = "/tmp/x",
            Channel = new[] { "Notification" },
            Site = new[] { "site-9" },
        };
        var qs = AuditExportHelpers.BuildQueryString(args, now);
        var parsed = HttpUtility.ParseQueryString(qs.TrimStart('?'));

        Assert.Equal("jsonl", parsed["format"]);
        Assert.Equal("Notification", parsed["channel"]);
        Assert.Equal("site-9", parsed["sourceSiteId"]);
        Assert.Equal("2026-05-20T11:00:00.0000000+00:00", parsed["fromUtc"]);
        Assert.Equal("2026-05-20T12:00:00.0000000+00:00", parsed["toUtc"]);
    }

    [Fact]
    public void BuildQueryString_MultiValueFilters_EmitOneKeyPerValue()
    {
        var now = DateTimeOffset.Parse("2026-05-20T12:00:00Z");
        var args = new AuditExportArgs
        {
            Since = "1h",
            Until = "2026-05-20T12:00:00Z",
            Format = "csv",
            Output = "/tmp/x",
            Channel = new[] { "ApiOutbound", "DbOutbound" },
            Kind = new[] { "ApiCall", "DbWrite" },
            Status = new[] { "Failed", "Parked" },
            Site = new[] { "site-1", "site-2" },
        };
        var qs = AuditExportHelpers.BuildQueryString(args, now);
        var parsed = HttpUtility.ParseQueryString(qs.TrimStart('?'));

        Assert.Equal(new[] { "ApiOutbound", "DbOutbound" }, parsed.GetValues("channel"));
        Assert.Equal(new[] { "ApiCall", "DbWrite" }, parsed.GetValues("kind"));
        Assert.Equal(new[] { "Failed", "Parked" }, parsed.GetValues("status"));
        Assert.Equal(new[] { "site-1", "site-2" }, parsed.GetValues("sourceSiteId"));
    }

    [Fact]
    public void BuildQueryString_OmitsUnsetMultiValueFilters()
    {
        var now = DateTimeOffset.Parse("2026-05-20T12:00:00Z");
        var args = new AuditExportArgs
        {
            Since = "1h",
            Until = "0h",
            Format = "csv",
            Output = "/tmp/x",
        };
        var qs = AuditExportHelpers.BuildQueryString(args, now);
        var parsed = HttpUtility.ParseQueryString(qs.TrimStart('?'));

        Assert.Null(parsed["channel"]);
        Assert.Null(parsed["kind"]);
        Assert.Null(parsed["status"]);
        Assert.Null(parsed["sourceSiteId"]);
    }

    [Fact]
    public void Export_MultipleChannelValues_SingleToken_AreAccepted()
    {
        // AllowMultipleArgumentsPerToken: --channel A B parses as two values.
        var root = AuditCommandTestHarness.BuildRoot();
        var parse = root.Parse(new[]
        {
            "audit", "export", "--since", "1h", "--until", "0h",
            "--format", "csv", "--output", "/tmp/out.csv",
            "--channel", "ApiOutbound", "DbOutbound",
        });
        Assert.Empty(parse.Errors);
    }

    [Fact]
    public void Export_MultipleChannelValues_RepeatedFlag_AreAccepted()
    {
        var root = AuditCommandTestHarness.BuildRoot();
        var parse = root.Parse(new[]
        {
            "audit", "export", "--since", "1h", "--until", "0h",
            "--format", "csv", "--output", "/tmp/out.csv",
            "--channel", "ApiOutbound", "--channel", "Notification",
        });
        Assert.Empty(parse.Errors);
    }

    [Fact]
    public void Export_MultiValueChannel_WithOneInvalidName_FailsFast()
    {
        // AcceptOnlyFromAmong validates EACH value of the multi-value option.
        var root = AuditCommandTestHarness.BuildRoot();
        var (exit, _, err) = AuditCommandTestHarness.Invoke(
            root, "audit", "export", "--since", "1h", "--until", "0h",
            "--format", "csv", "--output", "/tmp/out.csv",
            "--channel", "ApiOutbound", "OutboundApi");
        Assert.NotEqual(0, exit);
        Assert.NotEqual("", err);
    }

    // ---- Streaming export to file -----------------------------------------

    private sealed class BodyHandler : HttpMessageHandler
    {
        private readonly HttpStatusCode _status;
        private readonly Func<HttpContent> _content;
        public string? RequestPathAndQuery { get; private set; }

        public BodyHandler(HttpStatusCode status, Func<HttpContent> content)
        {
            _status = status;
            _content = content;
        }

        protected override Task<HttpResponseMessage> SendAsync(
            HttpRequestMessage request, CancellationToken cancellationToken)
        {
            RequestPathAndQuery = request.RequestUri!.PathAndQuery;
            return Task.FromResult(new HttpResponseMessage(_status) { Content = _content() });
        }
    }

    [Fact]
    public async Task RunExport_Success_StreamsResponseToOutputFile()
    {
        var path = Path.Combine(Path.GetTempPath(), $"audit-export-{Guid.NewGuid():N}.jsonl");
        try
        {
            var handler = new BodyHandler(HttpStatusCode.OK,
                () => new StringContent("{\"eventId\":\"e1\"}\n{\"eventId\":\"e2\"}\n",
                    Encoding.UTF8, "application/x-ndjson"));
            var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p");
            var output = new StringWriter();

            var exit = await AuditExportHelpers.RunExportAsync(
                client,
                new AuditExportArgs { Since = "1h", Until = "0h", Format = "jsonl", Output = path },
                output, DateTimeOffset.UtcNow);

            Assert.Equal(0, exit);
            Assert.True(File.Exists(path));
            var content = await File.ReadAllTextAsync(path);
            Assert.Contains("e1", content);
            Assert.Contains("e2", content);
            Assert.Contains("api/audit/export", handler.RequestPathAndQuery);
        }
        finally
        {
            if (File.Exists(path)) File.Delete(path);
        }
    }

    [Fact]
    public async Task RunExport_Http403_ReturnsExitCode2()
    {
        // CLI-018: an HTTP 403 on /api/audit/export must produce exit code 2 per the
        // documented CLI contract — the legacy bare-1 return masked auth failures
        // as generic command failures.
        var path = Path.Combine(Path.GetTempPath(), $"audit-export-403-{Guid.NewGuid():N}.csv");
        try
        {
            var handler = new BodyHandler(HttpStatusCode.Forbidden,
                () => new StringContent("{\"error\":\"nope\",\"code\":\"UNAUTHORIZED\"}", Encoding.UTF8, "application/json"));
            var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p");
            var output = new StringWriter();

            var exit = await AuditExportHelpers.RunExportAsync(
                client,
                new AuditExportArgs { Since = "1h", Until = "0h", Format = "csv", Output = path },
                output, DateTimeOffset.UtcNow);

            Assert.Equal(2, exit);
            Assert.False(File.Exists(path));
        }
        finally
        {
            if (File.Exists(path)) File.Delete(path);
        }
    }

    [Fact]
    public async Task RunExport_UnauthorizedCodeOnNon403_ReturnsExitCode2()
    {
        var path = Path.Combine(Path.GetTempPath(), $"audit-export-401-{Guid.NewGuid():N}.csv");
        try
        {
            var handler = new BodyHandler(HttpStatusCode.BadRequest,
                () => new StringContent("{\"error\":\"nope\",\"code\":\"FORBIDDEN\"}", Encoding.UTF8, "application/json"));
            var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p");
            var output = new StringWriter();

            var exit = await AuditExportHelpers.RunExportAsync(
                client,
                new AuditExportArgs { Since = "1h", Until = "0h", Format = "csv", Output = path },
                output, DateTimeOffset.UtcNow);

            Assert.Equal(2, exit);
        }
        finally
        {
            if (File.Exists(path)) File.Delete(path);
        }
    }

    [Fact]
    public async Task RunExport_Parquet501_PrintsServerMessageAndReturnsNonZero()
    {
        var path = Path.Combine(Path.GetTempPath(), $"audit-export-{Guid.NewGuid():N}.parquet");
        try
        {
            var handler = new BodyHandler(HttpStatusCode.NotImplemented,
                () => new StringContent("Parquet export is not yet supported.", Encoding.UTF8, "text/plain"));
            var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p");
            var output = new StringWriter();

            var exit = await AuditExportHelpers.RunExportAsync(
                client,
                new AuditExportArgs { Since = "1h", Until = "0h", Format = "parquet", Output = path },
                output, DateTimeOffset.UtcNow);

            Assert.NotEqual(0, exit);
            // No file should be written on the 501 path.
            Assert.False(File.Exists(path));
        }
        finally
        {
            if (File.Exists(path)) File.Delete(path);
        }
    }

    [Fact]
    public async Task RunExport_LargeBody_IsStreamedNotFullyBuffered()
    {
        // A ~8 MB body delivered via a streaming HttpContent. The export must copy it to
        // disk via Stream.CopyToAsync (chunked) — assert the file is written in full and
        // matches, which proves the streaming copy path works for multi-MB payloads.
        var path = Path.Combine(Path.GetTempPath(), $"audit-export-big-{Guid.NewGuid():N}.csv");
        const int totalBytes = 8 * 1024 * 1024;
        try
        {
            var handler = new BodyHandler(HttpStatusCode.OK,
                () => new StreamContent(new RepeatingStream((byte)'a', totalBytes)));
            var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p");
            var output = new StringWriter();

            var exit = await AuditExportHelpers.RunExportAsync(
                client,
                new AuditExportArgs { Since = "7d", Until = "0h", Format = "csv", Output = path },
                output, DateTimeOffset.UtcNow);

            Assert.Equal(0, exit);
            Assert.Equal(totalBytes, new FileInfo(path).Length);
        }
        finally
        {
            if (File.Exists(path)) File.Delete(path);
        }
    }

    /// <summary>
    /// A read-only stream that yields <paramref name="length"/> copies of a single byte
    /// without ever materialising the whole payload — used to simulate a large export
    /// body so the streaming copy can be exercised without an 8 MB literal.
    /// </summary>
    private sealed class RepeatingStream : Stream
    {
        private readonly byte _value;
        private long _remaining;

        public RepeatingStream(byte value, long length)
        {
            _value = value;
            _remaining = length;
        }

        public override bool CanRead => true;
        public override bool CanSeek => false;
        public override bool CanWrite => false;
        public override long Length => throw new NotSupportedException();
        public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); }

        public override int Read(byte[] buffer, int offset, int count)
        {
            if (_remaining <= 0) return 0;
            var n = (int)Math.Min(count, _remaining);
            for (var i = 0; i < n; i++) buffer[offset + i] = _value;
            _remaining -= n;
            return n;
        }

        public override void Flush() { }
        public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
        public override void SetLength(long value) => throw new NotSupportedException();
        public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
    }
}
