#nullable enable
using Microsoft.AspNetCore.Mvc.ApiExplorer;
using Microsoft.AspNetCore.Mvc.Controllers;
using Microsoft.AspNetCore.OpenApi;
using Microsoft.OpenApi;
using HashSet = System.Collections.Generic.HashSet<string>;

namespace ErsatzTV.Serialization;

/// <summary>
/// OpenAPI operation transformer that guarantees every operation carries a stable, non-empty
/// <c>operationId</c>. Only actions with <c>Name = "..."</c> on their HTTP-method attribute get an
/// id from the framework; the rest (~90 of ~240) would ship without one, which breaks generated
/// clients that key methods off <c>operationId</c>. Here we synthesize one from the
/// <see cref="ControllerActionDescriptor" /> (<c>ControllerName</c> + <c>ActionName</c>) whenever the
/// id is null/empty, and never rename an id the developer set explicitly.
/// <para>
/// Disambiguation is <b>structural, not visitation-order-dependent</b>: the colliding cases are the
/// HEAD/GET pairs that share a controller+action but differ only by verb. We precompute (from the
/// full <see cref="IApiDescriptionGroupCollectionProvider" />, filtered to the same
/// <c>general</c> group the "v1" document includes) the set of synthesized base ids that 2+ operations
/// share, and suffix <b>each</b> such operation by its HTTP verb (<c>&lt;base&gt;Get</c>,
/// <c>&lt;base&gt;Head</c>). A base id that is unique across the document stays unsuffixed. The result
/// is a pure function of (controller, action, verb): an SDK/framework change to discovery order can
/// no longer rename a generated client method. Explicit ids are recorded so a synthesized id can't
/// shadow one; a defensive counter remains a last-resort backstop for an impossible residual clash.
/// </para>
/// </summary>
public sealed class OperationIdOpenApiTransformer : IOpenApiOperationTransformer
{
    private const string GeneralGroupName = "general";

    private readonly HashSet _used = new(StringComparer.Ordinal);
    private readonly HashSet _collidingBaseIds;

    public OperationIdOpenApiTransformer(IApiDescriptionGroupCollectionProvider descriptionProvider)
        => _collidingBaseIds = ComputeCollidingBaseIds(descriptionProvider);

    public Task TransformAsync(
        OpenApiOperation operation,
        OpenApiOperationTransformerContext context,
        CancellationToken cancellationToken)
    {
        if (!string.IsNullOrEmpty(operation.OperationId))
        {
            // Keep developer-assigned ids verbatim; just reserve them so nothing synthesized collides.
            _used.Add(operation.OperationId);
            return Task.CompletedTask;
        }

        if (context.Description.ActionDescriptor is not ControllerActionDescriptor controllerAction)
        {
            return Task.CompletedTask;
        }

        string baseId = controllerAction.ControllerName + controllerAction.ActionName;

        // Structural, order-independent: if 2+ synthesized operations share this base id, suffix EACH
        // by its verb; otherwise keep the bare base. Pure function of (controller, action, verb).
        string candidate = _collidingBaseIds.Contains(baseId)
            ? baseId + PascalCaseVerb(context.Description.HttpMethod)
            : baseId;

        // Defensive backstop only — with verb suffixing this cannot fire for the known document, but
        // guarantees uniqueness if some future shape produces an unexpected clash.
        int suffix = 2;
        string finalId = candidate;
        while (!_used.Add(finalId))
        {
            finalId = candidate + suffix;
            suffix++;
        }

        operation.OperationId = finalId;
        return Task.CompletedTask;
    }

    private static HashSet ComputeCollidingBaseIds(IApiDescriptionGroupCollectionProvider provider)
    {
        var counts = new Dictionary<string, int>(StringComparer.Ordinal);
        foreach (ApiDescriptionGroup group in provider.ApiDescriptionGroups.Items)
        {
            foreach (ApiDescription description in group.Items)
            {
                // Mirror the "v1" document's ShouldInclude predicate.
                if (description.GroupName != GeneralGroupName)
                {
                    continue;
                }

                if (description.ActionDescriptor is not ControllerActionDescriptor controllerAction)
                {
                    continue;
                }

                // Only operations that will actually be *synthesized* participate — an explicit
                // Name= sets the route name (the framework's operationId), so it is excluded.
                if (!string.IsNullOrEmpty(controllerAction.AttributeRouteInfo?.Name))
                {
                    continue;
                }

                string baseId = controllerAction.ControllerName + controllerAction.ActionName;
                counts[baseId] = counts.TryGetValue(baseId, out int c) ? c + 1 : 1;
            }
        }

        return counts.Where(kvp => kvp.Value >= 2).Select(kvp => kvp.Key).ToHashSet(StringComparer.Ordinal);
    }

    private static string PascalCaseVerb(string? httpMethod)
    {
        if (string.IsNullOrEmpty(httpMethod))
        {
            return string.Empty;
        }

        return char.ToUpperInvariant(httpMethod[0]) + httpMethod[1..].ToLowerInvariant();
    }
}
