using System.Globalization;
using System.Text;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;

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

/// <summary>
/// Streaming CSV exporter for the Audit Log page.
///
/// <para>
/// The exporter iterates <see cref="IAuditLogRepository.QueryAsync"/> page by page
/// using its keyset cursor and writes each row to a destination
/// <see cref="Stream"/> as RFC 4180-compliant CSV. The output is flushed after
/// each page so a large export starts streaming to the client immediately
/// instead of buffering the whole result set in memory.
/// </para>
///
/// <para>
/// Output is capped at a caller-supplied <c>maxRows</c> ceiling; when the cap
/// is hit the service appends a <c># Capped at … rows. Use the CLI for larger
/// exports.</c> footer line so an operator can tell a truncated download from
/// a complete one. The header row contains the 21 columns of
/// <see cref="AuditEvent"/> in declaration order.
/// </para>
/// </summary>
public interface IAuditLogExportService
{
    /// <summary>
    /// Streams a CSV export of the rows matching <paramref name="filter"/> to
    /// <paramref name="output"/>, capping at <paramref name="maxRows"/>.
    /// </summary>
    /// <param name="filter">Repository filter to apply.</param>
    /// <param name="maxRows">
    /// Maximum number of data rows (excluding header / footer) to emit. The
    /// service stops paging once this is reached and appends a cap footer.
    /// </param>
    /// <param name="output">Destination stream — typically the HTTP response body.</param>
    /// <param name="ct">Cancellation token (e.g. <c>HttpContext.RequestAborted</c>).</param>
    /// <param name="pageSize">
    /// Optional override for the repository page size. Defaults to 1000 — large
    /// enough to amortise the per-query overhead, small enough that one page in
    /// memory is bounded.
    /// </param>
    /// <returns>A task that completes when all matching rows (up to <paramref name="maxRows"/>) have been written to <paramref name="output"/>.</returns>
    Task ExportAsync(
        AuditLogQueryFilter filter,
        int maxRows,
        Stream output,
        CancellationToken ct,
        int pageSize = AuditLogExportService.DefaultPageSize);
}

/// <inheritdoc cref="IAuditLogExportService"/>
public sealed class AuditLogExportService : IAuditLogExportService
{
    /// <summary>Default rows pulled per repository round-trip.</summary>
    public const int DefaultPageSize = 1000;

    private readonly IAuditLogRepository _repository;

    /// <summary>
    /// Initializes a new <see cref="AuditLogExportService"/>.
    /// </summary>
    /// <param name="repository">Audit log repository used to page through entries for export.</param>
    public AuditLogExportService(IAuditLogRepository repository)
    {
        _repository = repository ?? throw new ArgumentNullException(nameof(repository));
    }

    /// <inheritdoc/>
    public async Task ExportAsync(
        AuditLogQueryFilter filter,
        int maxRows,
        Stream output,
        CancellationToken ct,
        int pageSize = DefaultPageSize)
    {
        ArgumentNullException.ThrowIfNull(filter);
        ArgumentNullException.ThrowIfNull(output);
        if (maxRows <= 0) throw new ArgumentOutOfRangeException(nameof(maxRows), "maxRows must be positive.");
        if (pageSize <= 0) throw new ArgumentOutOfRangeException(nameof(pageSize), "pageSize must be positive.");

        // UTF-8 no-BOM: Excel will still recognise the CSV but the file stays
        // a clean ASCII-superset for downstream pipes / grep. The StreamWriter
        // leaves the underlying stream open so the controller can decide when
        // to dispose / complete it.
        await using var writer = new StreamWriter(
            output,
            new UTF8Encoding(encoderShouldEmitUTF8Identifier: false),
            bufferSize: 4096,
            leaveOpen: true);
        writer.NewLine = "\r\n"; // RFC 4180

        // Header — 21 columns in AuditEvent declaration order.
        await writer.WriteLineAsync(Header);

        int written = 0;
        AuditLogPaging cursor = new(PageSize: Math.Min(pageSize, maxRows));

        while (written < maxRows)
        {
            // Honour cancellation BEFORE we kick off another round-trip — this
            // is the deterministic cancellation point that the test pins on.
            ct.ThrowIfCancellationRequested();

            // Tighten the last page's size so we never pull more than the cap.
            var remaining = maxRows - written;
            var effectivePageSize = Math.Min(cursor.PageSize, remaining);
            var pageCursor = cursor with { PageSize = effectivePageSize };

            var page = await _repository.QueryAsync(filter, pageCursor, ct);
            if (page.Count == 0)
            {
                break;
            }

            foreach (var evt in page)
            {
                if (written >= maxRows)
                {
                    break;
                }
                await writer.WriteLineAsync(FormatCsvRow(AuditEventView.From(evt)));
                written++;
            }

            // Push bytes through the StreamWriter buffer into the underlying
            // stream so the client sees progress per-page instead of waiting
            // for the full export to buffer up.
            await writer.FlushAsync(ct);
            await output.FlushAsync(ct);

            // Last page (short read) — no more data to fetch.
            if (page.Count < effectivePageSize)
            {
                break;
            }

            var last = page[^1];
            cursor = new AuditLogPaging(
                PageSize: pageSize,
                // Canonical OccurredAtUtc is a DateTimeOffset; the keyset
                // cursor column is a UTC DateTime.
                AfterOccurredAtUtc: last.OccurredAtUtc.UtcDateTime,
                AfterEventId: last.EventId);
        }

        if (written >= maxRows)
        {
            // Cap footer — visible to operators so a truncated download is
            // distinguishable from a complete one. The "#" prefix keeps it
            // out of the data columns; spreadsheet tools will surface it as
            // a single-cell row.
            await writer.WriteLineAsync(
                $"# Capped at {maxRows.ToString(CultureInfo.InvariantCulture)} rows. Use the CLI for larger exports.");
            await writer.FlushAsync(ct);
            await output.FlushAsync(ct);
        }
    }

    // ─────────────────────────────────────────────────────────────────────
    // CSV helpers
    // ─────────────────────────────────────────────────────────────────────

    /// <summary>The 21 column names in <see cref="AuditEvent"/> declaration order.</summary>
    internal const string Header =
        "EventId,OccurredAtUtc,IngestedAtUtc,Channel,Kind,CorrelationId," +
        "SourceSiteId,SourceInstanceId,SourceScript,Actor,Target,Status," +
        "HttpStatus,DurationMs,ErrorMessage,ErrorDetail,RequestSummary," +
        "ResponseSummary,PayloadTruncated,Extra,ForwardState";

    /// <summary>
    /// Serialises one <see cref="AuditEventView"/> as a CSV row (no trailing newline).
    /// Each nullable column renders as the empty string when null; non-null
    /// scalars use invariant culture so an export taken on one locale parses
    /// cleanly on another.
    /// </summary>
    /// <param name="evt">The audit event view to format as a CSV row.</param>
    /// <returns>An RFC 4180 CSV row string (no trailing newline) for the supplied event.</returns>
    internal static string FormatCsvRow(AuditEventView evt)
    {
        var sb = new StringBuilder(256);
        AppendField(sb, evt.EventId.ToString(), first: true);
        AppendField(sb, FormatDate(evt.OccurredAtUtc));
        AppendField(sb, FormatDate(evt.IngestedAtUtc));
        AppendField(sb, evt.Channel.ToString());
        AppendField(sb, evt.Kind.ToString());
        AppendField(sb, evt.CorrelationId?.ToString());
        AppendField(sb, evt.SourceSiteId);
        AppendField(sb, evt.SourceInstanceId);
        AppendField(sb, evt.SourceScript);
        AppendField(sb, evt.Actor);
        AppendField(sb, evt.Target);
        AppendField(sb, evt.Status.ToString());
        AppendField(sb, evt.HttpStatus?.ToString(CultureInfo.InvariantCulture));
        AppendField(sb, evt.DurationMs?.ToString(CultureInfo.InvariantCulture));
        AppendField(sb, evt.ErrorMessage);
        AppendField(sb, evt.ErrorDetail);
        AppendField(sb, evt.RequestSummary);
        AppendField(sb, evt.ResponseSummary);
        AppendField(sb, evt.PayloadTruncated.ToString());
        AppendField(sb, evt.Extra);
        AppendField(sb, evt.ForwardState?.ToString());
        return sb.ToString();
    }

    private static string? FormatDate(DateTime? value) =>
        value?.ToString("O", CultureInfo.InvariantCulture);

    private static string FormatDate(DateTime value) =>
        value.ToString("O", CultureInfo.InvariantCulture);

    private static void AppendField(StringBuilder sb, string? value, bool first = false)
    {
        if (!first) sb.Append(',');
        if (string.IsNullOrEmpty(value))
        {
            return;
        }

        // RFC 4180: quote on comma / quote / CR / LF; double-up embedded quotes.
        bool needsQuoting = value.IndexOfAny(s_quoteTriggers) >= 0;
        if (!needsQuoting)
        {
            sb.Append(value);
            return;
        }

        sb.Append('"');
        foreach (var ch in value)
        {
            if (ch == '"')
            {
                sb.Append('"').Append('"');
            }
            else
            {
                sb.Append(ch);
            }
        }
        sb.Append('"');
    }

    private static readonly char[] s_quoteTriggers = { ',', '"', '\r', '\n' };
}
