using System.Globalization;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;

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

/// <summary>
/// Filter arguments for an <c>audit query</c> invocation. Mirrors the
/// <c>GET /api/audit/query</c> filter parameters; <see cref="Since"/>/<see cref="Until"/>
/// are time-specs (relative like <c>1h</c>/<c>7d</c>, or absolute ISO-8601).
/// <see cref="Channel"/>/<see cref="Kind"/>/<see cref="Status"/>/<see cref="Site"/>
/// are multi-valued — each supplied value becomes a repeated query-string param so
/// the server's multi-value <c>IN (…)</c> filter sees the full set.
/// </summary>
public sealed class AuditQueryArgs
{
    /// <summary>Start time spec (relative like 1h, or absolute ISO-8601).</summary>
    public string? Since { get; set; }
    /// <summary>End time spec (relative like 7d, or absolute ISO-8601).</summary>
    public string? Until { get; set; }
    /// <summary>Multi-valued channel filter.</summary>
    public string[] Channel { get; set; } = Array.Empty<string>();
    /// <summary>Multi-valued audit event kind filter.</summary>
    public string[] Kind { get; set; } = Array.Empty<string>();
    /// <summary>Multi-valued status filter.</summary>
    public string[] Status { get; set; } = Array.Empty<string>();
    /// <summary>Multi-valued site ID filter.</summary>
    public string[] Site { get; set; } = Array.Empty<string>();
    /// <summary>Target system or service filter.</summary>
    public string? Target { get; set; }
    /// <summary>Actor (user or system) filter.</summary>
    public string? Actor { get; set; }
    /// <summary>Operation correlation ID filter.</summary>
    public string? CorrelationId { get; set; }
    /// <summary>Script execution ID filter.</summary>
    public string? ExecutionId { get; set; }
    /// <summary>Parent execution ID filter.</summary>
    public string? ParentExecutionId { get; set; }
    /// <summary>Filter for errors only (status=Failed).</summary>
    public bool ErrorsOnly { get; set; }
    /// <summary>Page size for pagination.</summary>
    public int PageSize { get; set; } = 100;
}

/// <summary>
/// Pure helpers for the <c>audit query</c> subcommand: time-spec resolution, query-string
/// construction, and the keyset-cursor paging loop. Kept separate from the command wiring
/// so each piece is unit-testable without standing up the command tree.
/// </summary>
public static class AuditQueryHelpers
{
    // <number><unit> where unit is s/m/h/d — a relative offset back from "now".
    private static readonly Regex RelativeSpec = new(@"^(\d+)([smhd])$", RegexOptions.Compiled);

    /// <summary>
    /// Resolves a time-spec to an absolute <see cref="DateTimeOffset"/>. Accepts a
    /// relative offset (<c>30s</c>, <c>15m</c>, <c>1h</c>, <c>7d</c>) interpreted as
    /// <paramref name="now"/> minus the offset, or an absolute ISO-8601 timestamp.
    /// </summary>
    /// <param name="spec">The time specification string.</param>
    /// <param name="now">The current time used as reference for relative specs.</param>
    /// <exception cref="FormatException">The spec is neither a known relative form nor a parseable ISO-8601 timestamp.</exception>
    /// <returns>The resolved absolute <see cref="DateTimeOffset"/> in UTC.</returns>
    public static DateTimeOffset ResolveTimeSpec(string spec, DateTimeOffset now)
    {
        if (string.IsNullOrWhiteSpace(spec))
            throw new FormatException("Empty time value.");

        var match = RelativeSpec.Match(spec.Trim());
        if (match.Success)
        {
            var amount = int.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture);
            var offset = match.Groups[2].Value switch
            {
                "s" => TimeSpan.FromSeconds(amount),
                "m" => TimeSpan.FromMinutes(amount),
                "h" => TimeSpan.FromHours(amount),
                "d" => TimeSpan.FromDays(amount),
                _ => throw new FormatException($"Unknown time unit in '{spec}'."),
            };
            return now - offset;
        }

        if (DateTimeOffset.TryParse(spec, CultureInfo.InvariantCulture,
                DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var absolute))
        {
            return absolute;
        }

        throw new FormatException(
            $"Invalid time value '{spec}'. Use a relative offset (e.g. 1h, 24h, 7d) or an ISO-8601 timestamp.");
    }

    /// <summary>
    /// Builds the <c>?...</c> query string for <c>GET /api/audit/query</c> from the filter
    /// args plus an optional keyset cursor. Unset filters are omitted. The multi-valued
    /// <c>--channel</c>/<c>--kind</c>/<c>--status</c>/<c>--site</c> filters each emit ONE
    /// repeated query-string key per value (e.g. <c>channel=A&amp;channel=B</c>) so the
    /// server's multi-value <c>IN (…)</c> filter receives the full set. <c>--errors-only</c>
    /// maps to a single <c>status=Failed</c> and overrides any explicit <c>--status</c>.
    /// </summary>
    /// <param name="args">The audit query arguments.</param>
    /// <param name="now">The current time for resolving relative time specs.</param>
    /// <param name="afterOccurredAtUtc">Optional keyset cursor timestamp.</param>
    /// <param name="afterEventId">Optional keyset cursor event ID.</param>
    /// <returns>A URL query string (starting with <c>?</c>) containing the encoded filter parameters, or an empty string if no parameters are set.</returns>
    public static string BuildQueryString(
        AuditQueryArgs args, DateTimeOffset now, DateTimeOffset? afterOccurredAtUtc, string? afterEventId)
    {
        var parts = new List<string>();

        void Add(string key, string? value)
        {
            if (!string.IsNullOrWhiteSpace(value))
                parts.Add($"{key}={Uri.EscapeDataString(value)}");
        }

        void AddEach(string key, IReadOnlyList<string> values)
        {
            foreach (var value in values)
            {
                Add(key, value);
            }
        }

        if (!string.IsNullOrWhiteSpace(args.Since))
            Add("fromUtc", ResolveTimeSpec(args.Since!, now).ToString("o", CultureInfo.InvariantCulture));
        if (!string.IsNullOrWhiteSpace(args.Until))
            Add("toUtc", ResolveTimeSpec(args.Until!, now).ToString("o", CultureInfo.InvariantCulture));

        AddEach("channel", args.Channel);
        AddEach("kind", args.Kind);

        // --errors-only is a convenience shorthand for the Failed status filter. The
        // server's status filter is multi-value, but --errors-only stays a single-status
        // override: it pins status=Failed and supersedes any explicit --status values.
        if (args.ErrorsOnly)
        {
            Add("status", "Failed");
        }
        else
        {
            AddEach("status", args.Status);
        }

        AddEach("sourceSiteId", args.Site);
        Add("target", args.Target);
        Add("actor", args.Actor);
        Add("correlationId", args.CorrelationId);
        Add("executionId", args.ExecutionId);
        Add("parentExecutionId", args.ParentExecutionId);
        Add("pageSize", args.PageSize.ToString(CultureInfo.InvariantCulture));

        if (afterOccurredAtUtc.HasValue)
            Add("afterOccurredAtUtc", afterOccurredAtUtc.Value.ToString("o", CultureInfo.InvariantCulture));
        Add("afterEventId", afterEventId);

        return parts.Count == 0 ? string.Empty : "?" + string.Join("&", parts);
    }

    /// <summary>
    /// Executes the query: GETs <c>/api/audit/query</c>, renders each page with
    /// <paramref name="formatter"/>, and — when <paramref name="fetchAll"/> is set —
    /// follows <c>nextCursor</c> until the server returns a null cursor. Returns the
    /// process exit code (0 success, non-zero on HTTP/transport error).
    /// </summary>
    /// <param name="client">The management HTTP client.</param>
    /// <param name="args">The audit query arguments.</param>
    /// <param name="fetchAll">Whether to follow pagination cursors.</param>
    /// <param name="formatter">The audit result formatter.</param>
    /// <param name="output">The output writer for results.</param>
    /// <param name="now">The current time for resolving relative time specs.</param>
    /// <returns>A task that resolves to <c>0</c> on success, <c>1</c> on HTTP/transport error, or <c>2</c> on authorization failure.</returns>
    public static async Task<int> RunQueryAsync(
        ManagementHttpClient client,
        AuditQueryArgs args,
        bool fetchAll,
        IAuditFormatter formatter,
        TextWriter output,
        DateTimeOffset now)
    {
        DateTimeOffset? afterOccurredAtUtc = null;
        string? afterEventId = null;

        while (true)
        {
            var qs = BuildQueryString(args, now, afterOccurredAtUtc, afterEventId);
            var response = await client.SendGetAsync("api/audit/query" + qs, TimeSpan.FromSeconds(30));

            if (response.JsonData == null)
            {
                OutputFormatter.WriteError(
                    response.Error ?? "Audit query failed.", response.ErrorCode ?? "ERROR");
                // Surface the documented "authorization failure → exit 2"
                // contract for the audit REST surface too, not just /management.
                return CommandHelpers.IsAuthorizationFailure(response) ? 2 : 1;
            }

            using var doc = JsonDocument.Parse(response.JsonData);
            var root = doc.RootElement;

            var events = root.TryGetProperty("events", out var evts) && evts.ValueKind == JsonValueKind.Array
                ? evts.EnumerateArray().ToList()
                : new List<JsonElement>();
            formatter.WritePage(events, output);
            output.Flush();

            if (!fetchAll)
                return 0;

            if (!root.TryGetProperty("nextCursor", out var cursor)
                || cursor.ValueKind != JsonValueKind.Object)
            {
                return 0;
            }

            afterOccurredAtUtc = cursor.TryGetProperty("afterOccurredAtUtc", out var c1)
                && c1.ValueKind == JsonValueKind.String
                ? DateTimeOffset.Parse(c1.GetString()!, CultureInfo.InvariantCulture,
                    DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal)
                : null;
            afterEventId = cursor.TryGetProperty("afterEventId", out var c2)
                && c2.ValueKind == JsonValueKind.String
                ? c2.GetString()
                : null;

            // A malformed cursor (object present but missing both keys) would loop
            // forever — treat it as the end of results.
            if (afterOccurredAtUtc == null && afterEventId == null)
                return 0;
        }
    }
}
